Eine Spring Data Anwendung mit einem Apache Cassandra Backend

Eine Spring Data Anwendung mit einem Apache Cassandra Backend ist recht einfach zu erstellen, da Spring Data hierfür ein eigenes Subprojekt etabliert hat. Weiterhin gibt es selbstverständlich einen Embedded Cassandra Server als Bestandteil von Spring Boot. Ein externer Server ist kann über einen Docker-Container realisiert werden, das offizielle Docker-Image liegt auf dem Docker Hub.  

Cassandra im Docker-Container

Für eine einfache Test-Anwendung wird eine einzelne Cassandra-Instanz über den Aufruf 

docker run --rm --name cassandra -p 9142:9142 cassandra

gestartet. Damit läuft ein einzelner Cassandra-Knoten.

Nach dem Starten des Containers kann eine Konsole im Container geöffnet werden, mit deren Hilfe die Cassandra Shell cqlsh geöffnet werden kann:

docker exec --it cassandra /bin/bash

cqlsh

Damit kann dann für das folgende Beispiel nötige KeySpace sowie die Tabelle angelegt werden:

CREATE KEYSPACE PUBLISHING_KEYSPACE
WITH REPLICATION = {
'class': 'SimpleStrategy',
'replication_factor': 1
};

CREATE TABLE PUBLISHING(
publisher_name text,
title text,
isbn text,
price double,
pages int,
available boolean,
primary key(publisher_name, title, pages)
);

 

Die Spring Boot Anwendung

Wie üblich ist der Einstiegspunkt in die Spring Boot-Anwendung die pom.xml. Diese definiert neben dem Spring Boot Parent den Starter für Cassandra:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>org.javacream.training</groupId>
<artifactId>org.javacream.training.apache.cassandra.springboot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>org.javacream.training.apache.cassandra.springboot</name>
<description>Cassandra project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-cassandra</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

Damit ist die Konfiguration der Anbindung an den im Docker-Container laufenden Datenbankserver einfach:

@EnableCassandraRepositories
@Configuration
public class PublishingConfiguration

}

und die application.properties:

spring.data.cassandra.port=9142
spring.data.cassandra.keyspace-name=PUBLISHING_KEYSPACE

Entity und Repository

Die in der Cassandra-Datenbank abzulegende Datenstruktur wird über Annotationen gemapped:

@Table
public class Book {

@PrimaryKeyColumn(name = "publisher_name", ordinal = 0, type = PrimaryKeyType.PARTITIONED)
private String publisherName;
@PrimaryKeyColumn(name = "pages", ordinal = 2, type = PrimaryKeyType.CLUSTERED)
private int pages;
private double price;
private String isbn;
private boolean available;
@PrimaryKeyColumn(name = "title", ordinal = 1, type = PrimaryKeyType.CLUSTERED)
private String title;
//getter, setter, hashCode, equals, toString

}

Das Repository selbst ist dann eine einfache Schnittstellen-Erweiterung des CassandraRepository:

@Repository
public interface PublishingRepository extends CassandraRepository<Book, String>{

}

Die Anwendung

Die Anwendung selber schreibt nun nur 4 Bücher in die Datenbank:

@Component
public class PublishingApplication {

@Autowired private PublishingRepository publishingRepository;

@PostConstruct public void startApplication() {
Book cassandraInAction1 = new Book("Manning", 100, 19.99, "ISBN-1", false, "Cassandra in Action");
publishingRepository.save(cassandraInAction1);
Book cassandraInAction2 = new Book("Manning", 200, 29.99, "ISBN-2", true, "Cassandra in Action");
publishingRepository.save(cassandraInAction2);
Book cassandraInAction3 = new Book("Manning", 500, 39.99, "ISBN-3", true, "Cassandra in Action");
publishingRepository.save(cassandraInAction3);
Book java = new Book("Manning", 5000, 9.99, "ISBN-4", true, "Java");
publishingRepository.save(java);
}
}

Die geschriebenen Daten können nun einfach über die CQL-Shell abgefragt werden:

cqlsh:publishing_keyspace> select * from publishing where publisher_name='Manning' ;

publisher_name | title | pages | available | isbn | price
----------------+---------------------+-------+-----------+--------+-------
Manning | Cassandra in Action | 100 | False | ISBN-1 | 19.99
Manning | Cassandra in Action | 200 | True | ISBN-2 | 29.99
Manning | Cassandra in Action | 500 | True | ISBN-3 | 39.99
Manning | Java | 5000 | True | ISBN-4 | 9.99

(4 rows)

Oder mit einer zusätzlichen Selektion auf den Titel:

cqlsh:publishing_keyspace> select * from publishing where publisher_name='Manning' and title = 'Cassandra in Action';

publisher_name | title | pages | available | isbn | price
----------------+---------------------+-------+-----------+--------+-------
Manning | Cassandra in Action | 100 | False | ISBN-1 | 19.99
Manning | Cassandra in Action | 200 | True | ISBN-2 | 29.99
Manning | Cassandra in Action | 500 | True | ISBN-3 | 39.99

(3 rows)

Und schließlich noch auf die Seiten:

cqlsh:publishing_keyspace> select * from publishing where publisher_name='Manning' and title = 'Cassandra in Action' and pages = 200;

publisher_name | title | pages | available | isbn | price
----------------+---------------------+-------+-----------+--------+-------
Manning | Cassandra in Action | 200 | True | ISBN-2 | 29.99

(1 rows)

Die folgende Abfrage führt nun jedoch zu keinem sinnvollen Ergebnis:

cqlsh:publishing_keyspace> select * from publishing where pages = 200;
InvalidRequest: Error from server: code=2200 [Invalid query] message="PRIMARY KEY column "pages" cannot be restricted as preceding column "title" is not restricted"

 


Seminare zum Thema

Weiterlesen

Build-Tools für CI/CD

Die Build-Tools für CI/CD, also für die Umsetzung einer  Continuous Integration/Delivery/Deployment-Strategie haben sich in den letzten Jahren als gesetzte Produkte etabliert. Heutzutage haben Software-Projekte keinen konzeptuellen Aufwand mehr, eine komplette Build-Chain aufzusetzen. Es geht eigentlich “nur” noch um die gewünschte Ausprägung und eine Evaluierung der geeigneten Produkte. 

Die Werkzeuge

In einer klassisch aufgesetzten Build-Chain werden für die unterschiedlichen Aufgaben unterschiedliche Systeme eingesetzt:

  1. Das Source Code Management System (SCM) enthält alle Sourcen des Software-Projekts. Also natürlich die eigentlichen Quellcodes, Konfigurationsdateien und weitere Ressourcen. Daneben enthält es aber auch noch die Definition des Build-Prozesses.
  2. Ein Build-Tool erzeugt aus den Quellcodes die gebaute Anwendung bzw. “Artefakte”. Dabei werden Abhängigkeiten, auch transitiv, zu anderen Artefakten aufgelöst.
  3. Die Entwickler-Rechner haben Zugriff auf das SCM und legen darin ihre Quellen ab. Zusätzlich ist das Build-Tool installiert, so dass ein Build-Prozess lokal ausgeführt werden kann.
  4. Eine Build-Maschine holt sich vom SCM die Quellcodes sowie den Build-Prozess und baut daraus die Anwendung. 
  5. Das Artefakt-Repository hält alle Artefakte in einer hierarchischen Ablage inklusive Versionsnummer vor. Artefakte werden vom Build-Tool stets vom Artefakt-Repository geladen. Aber auch alle nachrangigen Prozesse, also beispielsweise Anwendertests oder die Produktionsplattform bedienen sich des Artefakt-Repositories. 

Als konkrete Produkte seien hier erwähnt:

  1. GIT in Kombination mit einem Git-Server wir GitHub. GitLab oder BitBucket als SCM, 
  2. Als Build-Werkzeug z.B. im Java-Umfeld Apache Maven oder Gradle. Für Web-Anwendungen ist npm sicherlich immer eine sinnvolle Möglichkeit.
  3. Als Build-Maschine ist Jenkins in vielen Unternehmen gesetzt. 
  4. Nexus und Artifactory sind gebräuchliche Repository Manager.

Selbstverständlich stellen diese Produkte nur eine subjektive Auswahl dar.

Gesamtbild

Das folgende Gesamtbild veranschaulicht das Zusammenspiel der Komponenten:

Die Build-Tools im Zusammenspiel

 


Seminare zum Thema

Weiterlesen

Apache Cassandra

Teil 1: Das Datenbanksystem

Apache Cassandra ist ein horizontal skalierendes, Master-loses Datenbanksystem, das durch einen Ring-Cluster realisiert wird. Doch was genau bedeutet dies?

Der Cluster

  • Wie bereits in einem vorherigen Artikel einer allgemeinen Einführung zum Thema NoSQL beschrieben, bedeutet horizontale Skalierung, dass beim Auftreten eines Engpasses bezüglich CPU, Speicher oder Storage einfach ein weiterer Knoten in einen Cluster hinzugefügt werden kann. Dieser kann nach kürzester Zeit Last übernehmen und somit den Engpass beheben.
    Ein horizontaler Cluster

     

  • In einem Master-losen Ring-Cluster sind alle Knoten des Systems gleichberechtigt: Jeder Knoten kann Anfragen eines Clients entgegen nehmen und entweder direkt selber verarbeiten oder an einen anderen Knoten delegieren. Solange von einem Client aus auch nur ein Knoten im Netz sichtbar ist, wird der Cluster funktionieren und antworten.

 

 

Der Ring-Cluster

 

Ob diese Antwort konsistent ist, hängt von der internen Kommunikation der Knoten im Ring ab. Dies ist die Aussage des bekannten CAP-Theorems.  

Replikation

Im Ring-Cluster von Cassandra wird ein Datensatz auf jeweils einem Knoten gehalten. Um bei einem Ausfall eines Cluster-Knotens Informations-Verlust zu verhindern, werden Datensätze auf mehrere Knoten repliziert. Dies wird durch den Replikationsfaktor RF beschrieben. So werden im folgenden Bild die Daten mit RF=3 abgelegt:

 

 

BASE

Schreibt ein Client Daten in den Ring-Cluster und liest beispielsweise ein anderer Client kurz danach die Daten, so können Inkonsistenzen auftreten. Es dauert eine gewisse Zeit, bis alle beteiligten Knoten des Rings alle Datenänderungen mitbekommen haben. Ist so ein Zeitfenster der Inkonsistenz fachlich möglich, so spricht man von einer BASE-Architektur, in der das E für die “Eventual Consistency” steht. “Eventual” ist hier selbstverständlich mit “letztendlich” zu übersetzen; letztendlich wird ein Ring-Cluster einen konsistenten Zustand erreichen.  

Tunable Consistency

Wird fachlich Konsistenz gefordert, so kann dies in einem Ring-Cluster dadurch erreicht werden, dass die Antwort des Clients so lange verzögert wird, bis die Daten auf allen replizierten Knoten angekommen sind. 

Eine nahe liegende Lösung wäre also, bei einer Schreib-Operation W zu verlangen, dass RF Knoten die Daten geschrieben haben. 

Dann kann ein Lesevorgang von einem beliebigen Knoten ausgeführt werden.

Also insgesamt:

W = RF und R = 1

Aber auch “anders herum” wird Konsistenz erreicht:

W = 1 und R = RF

Auf diese Art und Weise kann ausbalanciert werden, ob Wert auf schnelle Schreib- oder auf schnelle Lese-Vorgänge gelegt werden soll. Dies wird als  “Tunable Consistency” bezeichnet.

Beide Verfahren haben jedoch den Nachteil, dass bei einem Partitionsfehler, also einem Ausfall der Kommunikation zwischen Knoten, der gesamte Ring-Cluster in einem Standby-Modus geschaltet werden muss und keine Antwort liefern kann. Dies kann vermieden werden, in dem für Schreib und Lesevorgänge das Quorum benutzt wird:

W = R = (RF + 1)/2

Auch hier ist Konsistenz garantiert, allerdings muss nur noch in die Majorität der Knoten geschrieben werden.


Seminare zum Thema

Weiterlesen

Datenmodellierung für Apache Cassandra

Cassandra ist eine Datenbank, die nicht eindeutig in die 5 Kategorien der NoSQL-Datenbanksysteme eingeordnet werden kann: Einesteils ist sie Spalten-orientiert, andererseits wird für den effizienten Zugriff auf Daten ein “Partition Key” benötigt, also ein eindeutiger Primärschlüssel. Dies entspricht damit eher einem Key-Value-Store. Die Datenmodellierung für Apache Cassandra muss diese Besonderheiten berücksichtigen, um eine effiziente Datenverarbeitung ermöglichen zu können.

CQL – Die Cassandra Query Language

Um die Datenmodellierung mit Cassandra zu erlernen scheint es in erster Näherung sinnvoll, sich mit der Abfragesprache CQL zu beschäftigen. Diese Sprache wird wie SQL sowohl zur Struktur-Definition als auch für lesende und schreibende Zugriffe benutzt. 

Ein erster naiver Eindruck von CQL scheint nun zu zeigen, dass CQL und SQL sehr viel Analogien aufweisen. Es gibt ein CREATE TABLE ebenso wie ein SELECT FROM mit einer fast identischen Parametrisierung. 

Allerdings zeigt schon eine erste konkrete Umsetzung, dass diese Analogie äußerst beschränkt ist.

So muss zu allererst ein so genannter Keyspace eingerichtet werden, der in jedem Falle einen Replikationsfaktor definiert.

CREATE KEYSPACE PUBLISHING
WITH REPLICATION = {
'class': 'SimpleStrategy',
'replication_factor': 1
};

 

Dies ist in der Ring-Cluster-Architektur von Cassandra begründet, die eine Ausfallsicherheit durch Daten-Replikation über Knoten hinweg realisiert.

Das Anlegen einer Tabelle erfolgt dann scheinbar analog zu SQL. So wird im Folgenden eine Tabelle PUBLISHING angelegt, deren Intention es sein soll, einem Verleger Bücher zuzuordnen:

CREATE TABLE PUBLISHING(
publisher_name text,
title text,
isbn text,
price double,
pages int,
available boolean,
primary key(publisher_name, title)
);

Aber eine etwas genauere Analyse zeigt, dass diese Tabelle einen komplett anderen Aufbau hat als bei einem relationalen Modell. So würde ja in einem relationalen Datenbanksystem wohl eine Tabelle PUBLISHERS über einen Fremdschlüssel mit der Tabelle BOOKS verknüpft werden. 

Was ist nun aber der Sinn der obigen Tabellen-Definition:

  1. Der angegebene Primärschlüssel ist komplett etwas anderes. Der erste Teil, hier der publisher_name, ist der so genannte Partition Key. Cassandra verteilt die Datensätze an Hand des Partition Keys auf die Knoten des Clusters. Damit müssen effiziente Abfragen stets mit einer einleitenden WHERE <PARTITION_KEY> = <VALUE> eingeleitet werden. Ohne diese zentrale Selektion muss der Cluster die Abfrage auf allen Knoten des Rings ausführen, also eine Art “Full Table Scan” ausführen, was nicht sonderlich effizient ist.
  2. Der zweite Teil des Primary Key ist eine Liste von so genannten “Clustering Columns”.  Nur mit Hilfe einer Clustering Column kann ergänzend zum Partition Key die Abfrage erweitert werden. So kann in obigem Beispiel die Where-Klausel geschrieben werden als WHERE pulisher_name = 'Manning' and title = 'Cassandra in Action'. Eine weitere Selektion, also beispielsweise AND price = 19.99 ist nicht möglich. 

Die zweite obige Anmerkung zeigt den Spalten-orientierten Charakter von Cassandra. Die Clustering Column title ist nämlich ein Key in einer Map, dessen Value dann den zusätzlichen Buch-Informationen entspricht. Dieses Value wird von Cassandra aber als unstrukturiertes Dokument angenommen und wird deshalb in effizienten Abfragen nicht benutzt. 

Was wäre aber die Konsequenz, wenn eine weitere Clustering Column, also beispielsweise die pages mit in den Primary Key aufgenommen werden würden: 

primary key(publisher_name, title)

Fangen wir mit der Spalten-orientierten Arbeitsweise an: Die über den Partition Key definierte Map enthält als Schlüssel beispielsweise den Titel  'Cassandra in Action' . Das zugehörige Value ist nun aber wiederum eine Map! Und diese enthält für jeden Key-Eintrag der Seiten wiederum die restlichen Informationen des Buchs. Oder anders formuliert: Die PUBLISHING-Tabelle enthält für jeden Buch-Titel eine Liste von Seiten, die dann jeweils die ISBN, den Preis sowie die Verfügbarkeit enthält. 

Eine Abfrage der Form WHERE publisher_name = 'Manning' AND title = 'Cassandra in Action' AND pages = 500 ist vollkommen korrekt, ebenso title = 'Cassandra in Action'. Falsch hingegen wäre  WHERE publisher_name = 'Manning' AND pages = 500: Hier wird in der Navigation über die verschachtelten Maps eine Ebene ausgelassen, so dass wiederum eine sehr ineffiziente Abfrage resultieren würde.

Query First

Wir sehen damit, dass bei der Datenmodellierung mit Cassandra der Ausgangspunkt nicht das statische Datenmodell sein muss, sondern die auf den Datenbestand auszuführenden Abfragen. Dies wird als “Query First”-Ansatz bezeichnet.

Notwendige Konsequenz von Query First ist eine komplette Denormalisierung des Datenbestandes. Sollen verschiedene Kombinationen von Abfrage-Kriterien unterstützt werden so muss in Cassandra jeweils eine eigene Tabelle angelegt werden, so dass Daten redundant abgelegt werden müssen. 

Moderne Cassandra-Versionen

Die durch den “Query First”-Ansatz entstandenen Daten-Redundanzen sind für viele Modellierer schwer zu akzeptieren. Die Cassandra-Community hat deshalb in den letzten Jahren immer mehr Features und Optimierungen eingeführt, die dieses Problem lösen sollen. So können beispielsweise durch Indizes sehr wohl auch Abfragen bis in die Ebene der Value-Dokumente durchgeführt werden. Weiterhin führen Abfragen ohne die Angaben des Partition Key “nur” zu einer Warnung. Trotzdem müssen für einen sinnvollen Einsatz des Apache Cassandra-Datenbanksystems auch in den neuesten Versionen die technische Grundlagen sicher beherrscht und berücksichtigt werden. Sonst ist eine Ergänzung oder sogar Ablösung eines relationalen Datenbanksystems durch Cassandra sinnlos bzw. sogar  nachteilig. 


Seminare zum Thema

Weiterlesen

Referenzen und Objekte

Objekt-orientierte Programmierung ist bekanntermaßen ein sehr verbreitetes Konzept zur Modellierung komplexer Anwendungen. Die Programmiersprache Java hat bereits seit ihrer Einführung 1995 dieses Konzept aufgenommen. Auch die Laufzeitumgebung, also die Java Virtual Machine (JVM) berücksichtigt dieses Konzept und implementiert deshalb ein Speichermodell, das auf Referenzen und Objekte ausgerichtet ist.

Stack und Heap

Der Speicher der Java Virtual Machine ist in einen Stack- und einen Heap-Bereich unterteilt.

Stack und Heap der JVM

Diese Unterteilung ist zwar etwas grob, genügt aber für die folgende Betrachtung völlig.

Jede Java-Methode bekommt ihren eigenen Stack-Bereich zugeordnet. Innerhalb des Stacks werden sämtliche lokalen Variablen abgelegt. Wie im Objekt-orientierten notwendig sind diese Variablen in Java stets Referenzen auf Objekte. Diese werden im Heap-Bereich abgelegt.

Dies sei am Beispiel der folgenden main-Methode aufgezeigt:

public static void main(String[] args){
String message = "Hello";
String[] names = {"John", "Fritz"};
}

Im Speicher der JVM ergibt sich das folgende Bild:

Die main-Methode mit den lokalen Variablen message und names

Methodenaufruf

Nun definieren wir eine zweite Methode, called, diesmal mit 2 Parametern

public static void called(String str, String[] list){

}

Die methoden called mit den beiden Parametern str und list

Diese Methode wird aus main heraus aufgerufen:

public static void main(String[] args){
String message = "Hello";
String[] names = {"John", "Fritz"};
called(message, names);
}

Wie erfolgt nun jedoch die Übergabe der lokalen Variablen message und names in die Parameter str und list?

Wie werden die Parameter belegt?

Dazu wird die JVM die Werte der Referenzen als Kopie übergeben, also ein “CallByValue”-Verhalten auf der Ebene der Referenzen:

Copy By Value mit Referenzen

Im Endeffekt zeigen die Referenzen auf die selben Objekte im Heap.

Zuweisung von Werten

Was passiert aber nun, wenn in der aufgerufenen Methode den Parametern neue Werte zugewiesen werden? Hier ergibt sich ein feiner aber unglaublich wichtiger Unterschied: Belegen wir den Parameter str einfach mit einem neuen Wert, z.B. mit

str = "Goodbye";

so wird die Variable im Stack einfach mit einer Referenz auf das neue Objekt im Heap überschrieben:

Zuweisung eines neuen Wertes an den Parameter str

Wichtig hier ist, dass in der aufrufenden main-Methode die Variable message davon völlig unbeeinflusst bleibt.

Was passiert aber, wenn wir die Referenz auf das Array benutzen, um beispielsweise den Wert des zweiten Elements mit Index 1 auf “Paul” zu setzen?

list[1] ="Paul";

Nun ergibt sich ein anderes Bild:

Zuweisung eines neuen Wertes an das zweite Element der Liste

Hier ist klar ersichtlich, dass diese Änderung des Array-Objekts innerhalb der Methode called auch direkt Auswirkungen auf die aufrufende main-Methode hat: Nachdem die Methode called terminiert ist referenziert auch die Variable message auf das Array, dessen zweites Element nun auf “Paul” zeigt.

Also innerhalb von main

public static void main(String[] args){
String message = "Hello";
String[] names = {"John", "Fritz"};
called(message, names);
System.out.println(names[1]); //-> Paul
}

Zusammenfassung und Ausblick

Durch die Betrachtung des Speichermodells der Java Virtual Machine wird die Arbeitsweise eines Objekt-orientierten Programms unter Verwendung von Referenzen klar und verständlich. Das komplette Beispiel liegt als fertiges Eclipse-Projekt vor und enthält zusätzlich Konsolen-Ausgaben, die den Ablauf des Programms demonstrieren. Selbstverständlich kann aber auch der Eclipse-Debugger benutzt werden, um die Ausführung nachzuvollziehen.

Allerdings sind sicherlich auch noch einige Punkte nicht fundiert behandelt worden:

  • Das vorgestellte Speichermodell ist stark vereinfacht. Wie schaut es denn tatsächlich aus?
  • Was passiert eigentlich mit dem armen Objekt “Fritz”, das nun komplett losgelöst im Heap vorhanden ist?

Diese beiden Punkte werden in einem folgenden Artikel, der den Garbage Collector der JVM als Thema hat, behandelt.


Seminar zum Thema

Weiterlesen