Graphendatenbanken: Neo4j, Spring und Spring Data Neo4j

Im ersten Artikel über Graphendatenbanken ging es um die Motivation und einige grundlegende Konzepte. Dieser Artikel soll etwas konkreter werden: Es wird die Graphendatenbank Neo4j vorgestellt und Spring Data Neo4j genutzt, um ein auf Graphen basiertes Schema zu entwickeln.

Sowohl Neo4j als auch Spring Data Neo4j sind über Maven Central verfügbar. Daher ist die Einbindung in einem Maven-Projekt ein leichtes:

01
<dependencies>
02
    <dependency>
03
        <groupid>org.springframework.data</groupid>
04
        <artifactid>spring-data-neo4j</artifactid>
05
        <version>${version.spring.data.neo4j}</version>
06
    </dependency>
07
    <dependency>
08
        <groupid>org.springframework.data</groupid>
09
        <artifactid>spring-data-neo4j-tx</artifactid>
10
        <version>${version.spring.data.neo4j}</version>
11
    </dependency>
12
    <dependency>
13
        <groupid>org.neo4j</groupid>
14
        <artifactid>neo4j</artifactid>
15
        <version>${version.neo4j}</version>
16
    </dependency>
17
    <dependency>
18
        <groupid>org.neo4j</groupid>
19
        <artifactid>neo4j-kernel</artifactid>
20
        <version>${version.neo4j}</version>
21
    </dependency>
22
    <dependency>
23
        <groupid>org.neo4j</groupid>
24
        <artifactid>neo4j-cypher</artifactid>
25
        <version>${version.neo4j}</version>
26
    </dependency>
27
</dependencies>

Nun folgt die Modellierung von Domänenklassen. Wir greifen das Beispiel aus dem ersten Artikel auf und erzeugen Domänenklassen für Personen, Autoren und Artikel. Dabei nutzen wir konsequent die Vererbungshierarchie und erzeugen zunächst eine generische Basis für Knotentypen. Diese abstrakte Domainenklasse beinhaltet als wichtigstes Attribut die Node-ID, welche von Spring-Data-Neo4j benötigt und mit der Annotation @GraphId versehen wird. Zusätzlich führen wir mit einer UUID einen zweiten, redundanten Identifier ein, der eine gewisse Unabhängigkeit der Daten von der konkret eingesetzten Datenbank gewährleistet. Beispielsweise können so Datenbestände gemischt werden oder schlicht auf eine andere Persistenz umgezogen werden. Mit der Annotation @Indexed sorgen wir dafür, dass Spring-Data-Neo4j einen Lucene-Index erstellt. Mit dem unique forcieren wir, dass das der Identifier einzigartig ist. Schließlich hat sich bewährt, dass jeder Knoten über Timestamps verfügen, wann der Knoten erzeugt und zuletzt verändert wurde.

01
@NodeEntity
02
public abstract class Node {
03
 
04
    @GraphId
05
    private Long nodeId;
06
 
07
    @Indexed(unique = true)
08
    @NotNull
09
    private String uuid;
10
 
11
    @NotNull
12
    private Long created;
13
 
14
    @NotNull
15
    private Long lastModified;
16
 
17
    // Getter and setters ommitted
18
}

Nun erstellen wir eine Domänenklasse für Personen. Außerdem annotieren wir die Klasse mit @NodeEntity, um Spring-Data-Neo4j mitzuteilen, dass es sich um einen Knotentyp handelt. Die beiden Attribute Vorname und Nachname werden mit @Indexed annotiert, damit wir nach diesen Attributen suchen können. In diesem Beispiel beschränken wir uns auf die Attribute Vor- und Nachname, obwohl man in der Praxis noch einige Informationen mehr hinzufügen würde. Allerdings ist beim Hinzufügen von neuen Attributen immer zu beachten, ob es sich nicht eigentlich um einen weiteren Knotentyp handelt, der mit einer Relation verbunden wird. Beispielsweise würde man abhängig vom konkreten Anwendungsfall eine Adresse nicht als Attribute, sondern als eigenen Knotentyp definieren, da so N:M-Relationen abgebildet und eine feinere Semantik erreicht werden kann. Eine Person kann beispielsweise bei mehreren Adressen wohnen und eine Adresse kann von mehreren Personen bewohnt werden. Um eine feinere Semantik zu erreichen würden man beide Knotentypen mit mehreren Kantentypen verbinden, beispielsweise: Person “wohnt bei” Adresse oder Person “ist Hausmeister von” Adresse.

01
@NodeEntity
02
public class Person extends Node {
03
 
04
    @Size(min = 2, max = 80)
05
    @Indexed
06
    private String firstName;
07
 
08
    @Size(min = 2, max = 80)
09
    @Indexed
10
    private String lastName;
11
 
12
    // Getter and setters ommitted
13
}

Die Spezialisierung einer Person ist ein Autor. Es reicht aus eine leere Klasse erzeugen, um einen Autor von einer Person zu unterscheiden.

1
@NodeEntity
2
public class Author extends Person {
3
}

Und schließlich benötigen wir die Domänenklasse für Artikel.

01
@NodeEntity
02
public class Article extends Node {
03
 
04
    @Size(min = 10, max = 80)
05
    private String title;
06
 
07
    @Size(min = 40, max = 240)
08
    private String summary;
09
 
10
    @Size(min = 500)
11
    private String text;
12
 
13
    // Getter and setters ommitted
14
}

Nun sind wir soweit, dass wir die Knotentypen miteinander in Beziehung stellen können. Auch bei den Relationen ist eine Vererbungshierarchie möglich. Wie schon bei der Definition von Knotentypen erspart uns dies etwas Tipparbeit und erlaubt später das Arbeiten mit den Super-Klassen, ohne den genauen Typ der Relation zu kennen.

1
public abstract class AbstractRelationship {
2
 
3
    @GraphId
4
    private Long relationshipId;
5
 
6
    // Getter and setters ommitted
7
}

Nun erstellen wir eine Klasse, die die Beziehung “hat gelesen” zwischen einer Person und einem Artikel beschreibt. Dabei definieren wir in einem statischen String den Namen der Relation. Um Komplikationen zu vermeiden, sollte der Name eindeutig sein. Mit der Annotation @RelationshipEntity teilen wir Spring-Data-Neo4j mit, dass es sich um eine Kantentyp, d.h. eine Relation handelt. Eine Relation hat immer einen Elternknoten (oder Startknoten), der mit @StartNode annotiert wird, und ein Kindknoten (oder Endknoten), der mit @EndNode annotiert wird. In der Welt der Graphen spricht man von einem gerichteten Graphen. Visualisieren könnte man dies, indem man zwischen zwei Knoten eine Pfeil in Richtung des Kindes zieht. Zusätzlich können wir auch in den Relationen Attribute speichern. In diesem Beispiel wollen wir den Zeitpunkt speichern, wann eine Person einen Artikel gelesen hat. Es zeigt sich, dass dies zu einer natürlicheren Form der Datenmodellierung führt, denn die Information wird genau dort gespeichert, wo sie hingehört – in der Relation selbst. Einfach, verständlich und effektiv.

01
@RelationshipEntity(type = HasRead.NAME)
02
public class HasRead extends AbstractRelationship {
03
 
04
    public static final String NAME = "HAS_READ";
05
 
06
    @StartNode
07
    private Person person;
08
 
09
    @EndNode
10
    private Article article;
11
 
12
    private Long when;
13
 
14
    // Getter and setters ommitted
15
}

Nun beschreiben wir eine weitere Beziehung, und zwar dass ein Autor einen Artikel geschrieben hat. Neben dem Klassennamen unterscheidet sich auch der Name der Relation und der Typ des Startknotens.

01
@RelationshipEntity(type = HasWritten.NAME)
02
public class HasWritten extends AbstractRelationship {
03
 
04
    public static final String NAME = "HAS_WRITTEN";
05
 
06
    @StartNode
07
    private Author author;
08
 
09
    @EndNode
10
    private Article article;
11
 
12
    private Long when;
13
 
14
    // Getter and setters ommitted
15
}

Da wir nun über Domänen- und Relationenklassen verfügen stellt sich die Frage, wie wir auf die Daten zugreifen können. Dazu bedient sich Spring Data Neo4j dem Data-Access-Object-Pattern (DAO-Pattern) und abstrahiert es soweit, dass wir kaum noch etwas zu programmieren haben. In der Tat sieht es etwas nach “Magic” aus, denn wir implementieren nichts, sondern definieren nur noch einige Interfaces. Im nächsten Code-Beispiel ist eine solches DAO-Interface zu sehen. Zunächst wird das Interface mit der Annotation @Repository annotiert. Dies sorgt dafür, dass Spring Data Neo4j dieses Interface automatisch als DAO-Interface im Spring-Context erkennen kann. Mehr dazu später, wenn wir unsere Anwendung in Spring integrieren. Zusätzlich nutzen wir einen Transaktionskontext indem wir das Interface mit @Neo4jTransactional annotieren. Das Interface erbt Methoden von den übergeordneten Interaces GraphRepository, RelationshipOperationsRepository, CRUDRepository und NamedIndexRepository. Dies sind Methoden um Knoten zu suchen, zu erzeugen und Verknüpfungen zu erstellen. Zusätzlich definieren wir Methoden wie findByUuid, um Knoten vom Typ Person mit der angegebenen UUID zu suchen. Spring Data Neo4j kümmert sich darum, zur Laufzeit eine Implementierung für diese Methodendefinition bereitzustellen. Somit entfällt für uns die Implementierung fast vollständig und DAOs werden hauptsächlich deklarativ beschrieben.

01
@Repository
02
@Neo4jTransactional
03
public interface PersonRepository extends GraphRepository<Person>, RelationshipOperationsRepository<Person>, CRUDRepository<Person>, NamedIndexRepository<Person> {
04
 
05
    Person findByUuid(String uuid);
06
 
07
    Person findByFirstName(String firstName);
08
 
09
    Person findByLastName(String lastName);
10
 
11
}

Spring Data Neo4j abstrahiert für uns eine Menge Arbeit, die wir sonst von Hand erledigen müssten. Dabei lässt es aber uns die Freiheit, es selbst tun zu können, wenn es notwendig sein sollte. Aber Spring Data Neo4j unterstützt uns auch bei komplexeren Aufgaben. So ist es beispielsweise möglich, eine Anfrage ähnlich wie SQL zu schreiben. Dies erfolgt beispielsweise mit der Abfragesprache Cypher, die wie SQL relativ einfach zu verstehen ist. Im ersten Artikel interessierte uns die Frage, von wievielen Autoren ein Artikel geschrieben wurde. Dies lässt sich mit Cypher so ausdrücken:

1
START article=node(123) MATCH (author)-[rel:HAS_WRITTEN]->(article) RETURN COUNT(rel)

Zur Erklärung: Im START-Teil der Abfrage wird definiert, wo wir beginnen sollen zu suchen. Wir wissen die ID eines Artikels (123) und starten an diesem Knoten im Graphen. Auf diesen Knoten können wir mit “article” referenzieren. Im MATCH-Teil beschreiben wir nun, dass es Autoren gibt, die den Artikel geschrieben haben. Dabei ist “article” ein einzelner Knoten (wie zuvor definiert), aber “author” representiert alle Autoren, die mit einer Relation vom Typ “HAS_WRITTEN” verbunden sind. Auf die Autoren können wir mit “author” referenzieren und auch die Relation können wir referenzieren. Der RETURN-Teil gibt nun ein oder mehrere Ausgaben zurück. Das kann ein einzelner Knoten sein, eine Menge von Knoten, eine Relation, eine Menge von Relationen oder in unserem Fall ein Integer. Im RETURN-Teil zählen wir lediglich die Relationen.

Spring Data Neo4j unterstützt uns nun, indem wir nicht direkt auf die Java-API von Neo4j zugreifen müssen, sondern einfach das DAO-Interface erweitern. Dazu nutzen wir die Annotation @Query, in der wir den Cypher-Query-String definieren. Statt der festen ID wird nun ein Platzhalter {article} definiert. Die Methodensignatur muss für jeden Platzhalter einen Parameter liefern. Dies tun wir mit der Annotation @Param und dem Namen des Platzhalters “article”.

01
@Repository
02
@Neo4jTransactional
03
public interface AuthorRepository extends GraphRepository<Author>, RelationshipOperationsRepository<Author>, CRUDRepository<Author>, NamedIndexRepository<Author> {
04
 
05
    // other methods ommited
06
 
07
    @Query("START article=node({article}) MATCH (author)-[rel:HAS_WRITTEN]->(article) RETURN COUNT(rel)")
08
    Integer getNumberOfAuthors(@Param("article") Article article);
09
 
10
}

Nun geht’s ans Eingemachte. Wir möchten alles nutzen, was wir zuvor definiert haben. Daher müssen wir Neo4j zunächst integrieren. Dazu

Wie nutzt man nun ein solches Repository? Zunächst müssen wir dafür sorgen, dass Spring Data Neo4j die Repositories findet und Instanzen bildet. Wir konfigurieren Spring mittels Java-Config, indem wir eine Klasse erstellen und sie mit @Configuration annotieren. In dieser Klasse definieren wir eine Methode, die eine Instanz einer Embedded Neo4j Graph Database erzeugt und als Bean zur verfügung stellt. Weiterhin binden wir Spring Data Neo4j ein, indem wir die Klasse mit der Annotation @EnableNeo4jRepositories versehen und den Package-Path angeben, indem die DAOs zu finden sind. Schließlich lassen wir Spring nach Services suchen (@ComponentScan).

01
@Configuration
02
@EnableNeo4jRepositories(basePackages = { "de.schaeffernet.repository" })
03
@ComponentScan(basePackages = { "de.schaeffernet.services" })
04
public class Neo4JConfig extends Neo4jConfiguration {
05
 
06
    @Bean(name = "graphDatabaseService", destroyMethod = "shutdown")
07
    public GraphDatabaseService getEmbeddedGraphDatabase() {
08
        return new GraphDatabaseFactory().newEmbeddedDatabaseBuilder("/tmp/neo4j").setConfig(ShellSettings.remote_shell_enabled, Settings.TRUE).newGraphDatabase();
09
    }
10
 
11
}

Einen solchen Service möchte ich noch vorstellen, um zu zeigen, wie man mit den DAOs umgeht. Zunächst injizieren wir die DAOs mittels @Autowired. In der Methode printArticleOverview lassen wir uns vom Artikel DAO alle Artikel zurück liefern und iterieren über diese. Für jeden Artikel fragen wir die Anzahl der Autoren ab und geben eine Zeile im Log aus. Normalerweise sollte man Aufrufe wie findAll() vermeiden, wenn es geht, da man, je nach Datenbestand, sehr viele Daten in den Arbeitsspeicher lädt. Dies soll jedoch nur ein einfaches Beispiel sein. Weiterhin ist zu beachten, dass wir in den Service-Klassen die Komplexität deutlich verringert haben, da die Cypher-Abfrage im DAO steckt. Dies ist sicherlich kein zu unterschätzender Vorteil. Oftmals kann so ein deutlich einfacherer Quellcode geschaffen werden.

01
@Service
02
public class AuthorService {
03
 
04
    @Autowired
05
    private AuthorRepository authorRepository;
06
 
07
    @Autowired
08
    private ArticleRepository articleRepository;
09
 
10
    Logger logger; // Ommited
11
 
12
    public final void printArticleOverview() {
13
        EndResult<Article> result = articleRepository.findAll();
14
        for (Article article : result) {
15
            logger.debug(String.format("Article %s was written by %d authors.", article.getTitle(), authorRepository.getNumberOfAuthors(article)));
16
        }
17
    }
18
 
19
}

Fazit

Im ersten Artikel wurden die theoretischen Grundlagen geschaffen, die in diesem zweiten Artikel durch praxistaugliche Beispiele unterfüttert wurden. Es wurde gezeigt, wie ein Graphen-Schema mit Knoten- und Kantentypen deklarativ modelliert wird und wie mit “Data-Access-Interfaces” eine Persistenzschicht deklariert wird. Zudem wurde die Abfragesprache Cypher angerissen, die die Formulierung von einfachen und komplexeren Fragestellungen im Graphen ermöglicht. Schließlich wurde Neo4j und Spring Data Neo4j in Spring integriert und ein einfaches Beispiel anhand einer Service-Klasse vorgeführt.

Comments are closed