Una guida a Cassandra con Java

1. Panoramica

Questo tutorial è una guida introduttiva al database Apache Cassandra utilizzando Java.

Troverai spiegati i concetti chiave, insieme a un esempio funzionante che copre i passaggi di base per connettersi e iniziare a lavorare con questo database NoSQL da Java.

2. Cassandra

Cassandra è un database NoSQL scalabile che fornisce disponibilità continua senza un singolo punto di errore e offre la capacità di gestire grandi quantità di dati con prestazioni eccezionali.

Questo database utilizza un design ad anello invece di utilizzare un'architettura master-slave. Nel design ad anello, non esiste un nodo master: tutti i nodi partecipanti sono identici e comunicano tra loro come pari.

Ciò rende Cassandra un sistema scalabile orizzontalmente consentendo l'aggiunta incrementale di nodi senza la necessità di riconfigurazione.

2.1. Concetti chiave

Cominciamo con una breve rassegna di alcuni dei concetti chiave di Cassandra:

  • Cluster : una raccolta di nodi o Data Center disposti in un'architettura ad anello. Ad ogni cluster deve essere assegnato un nome, che verrà successivamente utilizzato dai nodi partecipanti
  • Keyspace : se provieni da un database relazionale, lo schema è il rispettivo keyspace in Cassandra. Il keyspace è il contenitore più esterno per i dati in Cassandra. Gli attributi principali da impostare per keyspace sono il fattore di replica , la strategia di posizionamento della replica e le famiglie di colonne
  • Famiglia di colonne: le famiglie di colonne in Cassandra sono come le tabelle nei database relazionali. Ogni famiglia di colonne contiene una raccolta di righe rappresentate da una mappa . La chiave offre la possibilità di accedere ai dati correlati insieme
  • Colonna - Una colonna in Cassandra è una struttura di dati che contiene un nome di colonna, un valore e un timestamp. Le colonne e il numero di colonne in ogni riga possono variare a differenza di un database relazionale in cui i dati sono ben strutturati

3. Utilizzo del client Java

3.1. Dipendenza da Maven

Dobbiamo definire la seguente dipendenza Cassandra nel pom.xml , l'ultima versione della quale può essere trovata qui:

 com.datastax.cassandra cassandra-driver-core 3.1.0 

Per testare il codice con un server database incorporato dovremmo anche aggiungere la dipendenza cassandra-unit , l'ultima versione della quale può essere trovata qui:

 org.cassandraunit cassandra-unit 3.0.0.1 

3.2. Connessione a Cassandra

Per connetterci a Cassandra da Java, dobbiamo creare un oggetto Cluster .

Un indirizzo di un nodo deve essere fornito come punto di contatto. Se non forniamo un numero di porta, verrà utilizzata la porta predefinita (9042).

Queste impostazioni consentono al driver di scoprire la topologia corrente di un cluster.

public class CassandraConnector { private Cluster cluster; private Session session; public void connect(String node, Integer port) { Builder b = Cluster.builder().addContactPoint(node); if (port != null) { b.withPort(port); } cluster = b.build(); session = cluster.connect(); } public Session getSession() { return this.session; } public void close() { session.close(); cluster.close(); } }

3.3. Creazione del Keyspace

Creiamo il nostro keyspace " libreria ":

public void createKeyspace( String keyspaceName, String replicationStrategy, int replicationFactor) { StringBuilder sb = new StringBuilder("CREATE KEYSPACE IF NOT EXISTS ") .append(keyspaceName).append(" WITH replication = {") .append("'class':'").append(replicationStrategy) .append("','replication_factor':").append(replicationFactor) .append("};"); String query = sb.toString(); session.execute(query); }

Ad eccezione di keyspaceName, dobbiamo definire altri due parametri, replicationFactor e replicationStrategy . Questi parametri determinano rispettivamente il numero di repliche e il modo in cui le repliche verranno distribuite sull'anello.

Con la replica Cassandra garantisce affidabilità e tolleranza ai guasti archiviando copie di dati in più nodi.

A questo punto possiamo verificare che il nostro keyspace sia stato creato con successo:

private KeyspaceRepository schemaRepository; private Session session; @Before public void connect() { CassandraConnector client = new CassandraConnector(); client.connect("127.0.0.1", 9142); this.session = client.getSession(); schemaRepository = new KeyspaceRepository(session); }
@Test public void whenCreatingAKeyspace_thenCreated() { String keyspaceName = "library"; schemaRepository.createKeyspace(keyspaceName, "SimpleStrategy", 1); ResultSet result = session.execute("SELECT * FROM system_schema.keyspaces;"); List matchedKeyspaces = result.all() .stream() .filter(r -> r.getString(0).equals(keyspaceName.toLowerCase())) .map(r -> r.getString(0)) .collect(Collectors.toList()); assertEquals(matchedKeyspaces.size(), 1); assertTrue(matchedKeyspaces.get(0).equals(keyspaceName.toLowerCase())); }

3.4. Creazione di una famiglia di colonne

Ora possiamo aggiungere i primi "libri" della famiglia di colonne allo spazio delle chiavi esistente:

private static final String TABLE_NAME = "books"; private Session session; public void createTable() { StringBuilder sb = new StringBuilder("CREATE TABLE IF NOT EXISTS ") .append(TABLE_NAME).append("(") .append("id uuid PRIMARY KEY, ") .append("title text,") .append("subject text);"); String query = sb.toString(); session.execute(query); }

Di seguito viene fornito il codice per verificare che la famiglia di colonne sia stata creata:

private BookRepository bookRepository; private Session session; @Before public void connect() { CassandraConnector client = new CassandraConnector(); client.connect("127.0.0.1", 9142); this.session = client.getSession(); bookRepository = new BookRepository(session); }
@Test public void whenCreatingATable_thenCreatedCorrectly() { bookRepository.createTable(); ResultSet result = session.execute( "SELECT * FROM " + KEYSPACE_NAME + ".books;"); List columnNames = result.getColumnDefinitions().asList().stream() .map(cl -> cl.getName()) .collect(Collectors.toList()); assertEquals(columnNames.size(), 3); assertTrue(columnNames.contains("id")); assertTrue(columnNames.contains("title")); assertTrue(columnNames.contains("subject")); }

3.5. Modifica della famiglia di colonne

Un libro ha anche un editore, ma non è possibile trovare una colonna di questo tipo nella tabella creata. Possiamo usare il seguente codice per modificare la tabella e aggiungere una nuova colonna:

public void alterTablebooks(String columnName, String columnType) { StringBuilder sb = new StringBuilder("ALTER TABLE ") .append(TABLE_NAME).append(" ADD ") .append(columnName).append(" ") .append(columnType).append(";"); String query = sb.toString(); session.execute(query); }

Assicuriamoci che il nuovo editore di colonne sia stato aggiunto:

@Test public void whenAlteringTable_thenAddedColumnExists() { bookRepository.createTable(); bookRepository.alterTablebooks("publisher", "text"); ResultSet result = session.execute( "SELECT * FROM " + KEYSPACE_NAME + "." + "books" + ";"); boolean columnExists = result.getColumnDefinitions().asList().stream() .anyMatch(cl -> cl.getName().equals("publisher")); assertTrue(columnExists); }

3.6. Inserimento di dati nella famiglia di colonne

Ora che la tabella dei libri è stata creata, siamo pronti per iniziare ad aggiungere dati alla tabella:

public void insertbookByTitle(Book book) { StringBuilder sb = new StringBuilder("INSERT INTO ") .append(TABLE_NAME_BY_TITLE).append("(id, title) ") .append("VALUES (").append(book.getId()) .append(", '").append(book.getTitle()).append("');"); String query = sb.toString(); session.execute(query); }

È stata aggiunta una nuova riga nella tabella "libri", quindi possiamo verificare se la riga esiste:

@Test public void whenAddingANewBook_thenBookExists() { bookRepository.createTableBooksByTitle(); String title = "Effective Java"; Book book = new Book(UUIDs.timeBased(), title, "Programming"); bookRepository.insertbookByTitle(book); Book savedBook = bookRepository.selectByTitle(title); assertEquals(book.getTitle(), savedBook.getTitle()); }

Nel codice di test sopra abbiamo utilizzato un metodo diverso per creare una tabella denominata booksByTitle:

public void createTableBooksByTitle() { StringBuilder sb = new StringBuilder("CREATE TABLE IF NOT EXISTS ") .append("booksByTitle").append("(") .append("id uuid, ") .append("title text,") .append("PRIMARY KEY (title, id));"); String query = sb.toString(); session.execute(query); }

In Cassandra una delle migliori pratiche consiste nell'usare il modello di una tabella per query. Ciò significa che per una query diversa è necessaria una tabella diversa.

In our example, we have chosen to select a book by its title. In order to satisfy the selectByTitle query, we have created a table with a compound PRIMARY KEY using the columns, title and id. The column title is the partitioning key while the id column is the clustering key.

This way, many of the tables in your data model contain duplicate data. This is not a downside of this database. On the contrary, this practice optimizes the performance of the reads.

Let's see the data that are currently saved in our table:

public List selectAll() { StringBuilder sb = new StringBuilder("SELECT * FROM ").append(TABLE_NAME); String query = sb.toString(); ResultSet rs = session.execute(query); List books = new ArrayList(); rs.forEach(r -> { books.add(new Book( r.getUUID("id"), r.getString("title"), r.getString("subject"))); }); return books; }

A test for query returning expected results:

@Test public void whenSelectingAll_thenReturnAllRecords() { bookRepository.createTable(); Book book = new Book( UUIDs.timeBased(), "Effective Java", "Programming"); bookRepository.insertbook(book); book = new Book( UUIDs.timeBased(), "Clean Code", "Programming"); bookRepository.insertbook(book); List books = bookRepository.selectAll(); assertEquals(2, books.size()); assertTrue(books.stream().anyMatch(b -> b.getTitle() .equals("Effective Java"))); assertTrue(books.stream().anyMatch(b -> b.getTitle() .equals("Clean Code"))); }

Everything is fine till now, but one thing has to be realized. We started working with table books, but in the meantime, in order to satisfy the select query by title column, we had to create another table named booksByTitle.

The two tables are identical containing duplicated columns, but we have only inserted data in the booksByTitle table. As a consequence, data in two tables is currently inconsistent.

We can solve this using a batch query, which comprises two insert statements, one for each table. A batch query executes multiple DML statements as a single operation.

An example of such query is provided:

public void insertBookBatch(Book book) { StringBuilder sb = new StringBuilder("BEGIN BATCH ") .append("INSERT INTO ").append(TABLE_NAME) .append("(id, title, subject) ") .append("VALUES (").append(book.getId()).append(", '") .append(book.getTitle()).append("', '") .append(book.getSubject()).append("');") .append("INSERT INTO ") .append(TABLE_NAME_BY_TITLE).append("(id, title) ") .append("VALUES (").append(book.getId()).append(", '") .append(book.getTitle()).append("');") .append("APPLY BATCH;"); String query = sb.toString(); session.execute(query); }

Again we test the batch query results like so:

@Test public void whenAddingANewBookBatch_ThenBookAddedInAllTables() { bookRepository.createTable(); bookRepository.createTableBooksByTitle(); String title = "Effective Java"; Book book = new Book(UUIDs.timeBased(), title, "Programming"); bookRepository.insertBookBatch(book); List books = bookRepository.selectAll(); assertEquals(1, books.size()); assertTrue( books.stream().anyMatch( b -> b.getTitle().equals("Effective Java"))); List booksByTitle = bookRepository.selectAllBookByTitle(); assertEquals(1, booksByTitle.size()); assertTrue( booksByTitle.stream().anyMatch( b -> b.getTitle().equals("Effective Java"))); }

Nota: a partire dalla versione 3.0, è disponibile una nuova funzione chiamata "Viste materializzate", che potremmo utilizzare al posto delle query in batch . Un esempio ben documentato per "Viste materializzate" è disponibile qui.

3.7. Eliminazione della famiglia di colonne

Il codice seguente mostra come eliminare una tabella:

public void deleteTable() { StringBuilder sb = new StringBuilder("DROP TABLE IF EXISTS ").append(TABLE_NAME); String query = sb.toString(); session.execute(query); }

La selezione di una tabella che non esiste nello spazio delle chiavi genera un'eccezione InvalidQueryException: tabelle non configurate :

@Test(expected = InvalidQueryException.class) public void whenDeletingATable_thenUnconfiguredTable() { bookRepository.createTable(); bookRepository.deleteTable("books"); session.execute("SELECT * FROM " + KEYSPACE_NAME + ".books;"); }

3.8. Eliminazione del Keyspace

Infine, cancelliamo lo spazio delle chiavi:

public void deleteKeyspace(String keyspaceName) { StringBuilder sb = new StringBuilder("DROP KEYSPACE ").append(keyspaceName); String query = sb.toString(); session.execute(query); }

E verifica che lo spazio delle chiavi sia stato eliminato:

@Test public void whenDeletingAKeyspace_thenDoesNotExist() { String keyspaceName = "library"; schemaRepository.deleteKeyspace(keyspaceName); ResultSet result = session.execute("SELECT * FROM system_schema.keyspaces;"); boolean isKeyspaceCreated = result.all().stream() .anyMatch(r -> r.getString(0).equals(keyspaceName.toLowerCase())); assertFalse(isKeyspaceCreated); }

4. Conclusione

Questo tutorial ha coperto i passaggi di base della connessione e dell'utilizzo del database Cassandra con Java. Alcuni dei concetti chiave di questo database sono stati discussi anche per aiutarti a iniziare.

L'implementazione completa di questo tutorial può essere trovata nel progetto Github.