I database NoSQL

II parte: MongoDB, un database scalabile e orientato ai documentidi

In questo articolo, dopo un‘analisi delle caratteristiche e dei punti di forza di MongoDB, lo vedremo in azione: installazione, utilizzo della shell, operazioni CRUD, interrogazioni con il potente paradigma MapReduce. Infine presenteremo due tecniche per l‘utilizzo di MongoDB con Java.

Caratteristiche

Il nome di MongoDB deriva da "humongous" [1], un termine slang con il significato di "enorme", "immenso"; e si riferisce ovviamente alla capacità di MongoDB di immagazzinare una grande mole di dati. Le caratteristiche fondamentali di questo database NoSQL, che analizzeremo, sono:

  • modello di dati orientato ai documenti (oggetti JSON), senza schema, indicizzati e raggruppati in collezioni;
  • capacità di scalare orizzontalmente in modo molto semplice (auto-sharding);
  • replicazione asincrona;
  • MapReduce.

Modello di dati

Come già anticipato, un database MongoDB permette di immagazzinare oggetti JSON raggruppati in collezioni, senza schema, a loro volta raggruppate in database indipendenti. Un server MongoDB può contenere più database. Per quanto riguarda i tipi di dati, MongoDB, oltre ai tipi standard di JSON (null, boolean, integer, string, double, array e object) supporta anche date, object id, binary data, regular expression e code.

Cominciamo con un esempio. Un'istanza MongoDB può contenere tra le altre, una collezione di utenti, chiamiamola users, contenente a sua volta tre utenti:

// utente admin
{
      name : "admin",
      password : "8us11oo09",
      groups : [ "administrators" , "system" ],
      email : "info@mokabyte.it"
}
// utente onof
{
      name : "onof",
      openId : "http://onof80.blogspot.com",
      groups : [ "users" ],
      birthdate : new Date(1980, 2, 8),
      email : "onofrio.panzarino@gmail.com"
}
// utente mb
{
      name : "mb",
      password : "veryLongOne",
      score : 31,
      email : "info@mokabyte.it"
}

Com'è evidente, i tre utenti hanno schemi diversi, e la collezione può contenerli tutti. Se volessimo collegare l'utente ai messaggi che ha lasciato nel sistema, abbiamo due possibilità: la prima è di creare un'altra collezione, messages, e aggiungere a ogni messaggio una proprietà sender, con valore il name dell'utente:

// messaggi
{
      sent : new Date(2010, 3, 5),
      text : "Ciao a tutti, ..."
      sender : "user1",
      views : 120
}
 
{
      sent : new Date(2010, 3, 6),
      text : "Articolo MokaByte, ...",
      sender : "user1"
      views : 300,
      tags : [ "Java" ]
}

Oppure potremmo annidare i messaggi nell'utente: infatti, un documento può contenere, a sua volta, uno o più documenti, anche in più livelli, ad esempio:

// documenti innestati
{
      name : "user1",
      address : {
             zipCode : "60100",
             country : "Italy"
      },
      messages : {
             {
                    sent : new Date(2010, 3, 5),
                          text : "Ciao a tutti, ...",
                    views : 120
             },
             {
                    sent : new Date(2010, 3, 18),
                    text : "Articolo MokaByte, ...",
                    views : 300,
                    tags : [ "Java" ]
             }
      }
}

Ora, immaginiamo di voler interrogare il database per ottenere l'indirizzo e-mail dell'utente che ha inviato un certo messaggio. Poiche' MongoDB non ha la possibilità, come invece i database relazionali, di eseguire interrogazioni con JOIN lato server, il primo approccio potrebbe portare a un degrado delle prestazioni, perche' si tradurrebbe in due ricerche, una nella collezione messages e una seconda nella collezione degli utenti. Invece, annidando i documenti, potrebbe essere eseguita con una sola richiesta, quindi questo secondo approccio dovrebbe essere il preferito. Se, però, ad esempio, volessimo fare una ricerca tra i messaggi per avere i primi dieci messaggi di lunghezza maggiore e che sono stati inviati dopo una certa data, allora il secondo approccio porterebbe a query più difficoltose. Inoltre, ovviamente, non ha senso annidare (completamente) documenti con relazione molti-a-molti.

La decisione, quindi, se nidificare un oggetto, o lasciarlo in un'altra collezione e referenziarlo, deve essere fatta in maniera ben ponderata e dipendente dal contesto, perche' una decisione sbagliata potrebbe portare a una significativa perdita di prestazioni. Si potrebbe dire che lo sforzo principale di design del database consiste in questo tipo di scelte: non c'è infatti una tecnica che funziona sempre (o quasi) come la normalizzazione dei database relazionali, ma la scelta di design dipende dalla specifica applicazione che stiamo realizzando. Questo, in verità, è un principio di massima valido nella maggior parte dei database NoSQL.

Indicizzazione

Gli indici sono molto importanti perche', come per i database relazionali, servono all'ottimizzatore per velocizzare la ricerca e l'ordinamento. MongoDB crea automaticamente un indice sulla proprietà _id in ogni collezione. Se un oggetto inserito nel database non ha un valore impostato per la proprietà _id, ne viene generato uno, per garantire l'unicità degli oggetti. Per aggiungere un nuovo indice si usa il comando ensureIndex, nel client. Ad esempio, continuando con l'esempio precedente, per creare un indice sulla proprietà name nella collezione users:

db.users.ensureIndex( {  name : 1 } )

Gli indici possono essere creati non solo su più proprietà, ma anche su documenti contenuti, ad esempio

db.users.ensureIndex( {  name : 1, address : 1 } )

aggiunge un indice sulle proprietà name e address, che è un documento. Ovviamente la scelta di quali proprietà indicizzare è fortemente dipendente da quali interrogazioni verranno fatte più frequentemente, ma poiche' ogni indice creato in una collezione rallenta le operazioni di scrittura, è necessario raggiungere un compromesso tra velocità di interrogazione e velocità di scrittura.

MongoDB, inoltre, permette gli indici per l'univocità: un indice univoco non permette di inserire più di un oggetto con un certo valore per le proprietà indicizzate, analogamente ai vincoli di unicità dei database relazionali. Inoltre non permette di inserire più di un oggetto in cui non sia specificata quella o quelle proprietà (perche' corrisponde a un valore null per quelle proprietà).

Se si prevede che una certa collezione ha molti elementi senza una certa proprietà specificata, ma si vuole dare la possibilità di fare interrogazioni ottimizzate proprio su quella proprietà per gli oggetti che invece l'hanno specificata, si possono prendere in considerazioni gli indici sparsi. Ad esempio, se nella collezione users, abbiamo molti utenti senza la proprietà openId (perche' magari entrano nel sistema specificando username e password), ma vogliamo poter fare delle ricerche proprio per OpenID, possiamo creare un indice sparso:

db.users.ensureIndex( { openId : 1 } , { sparse : true } );

Installazione e amministrazione

L'installazione, se non ci sono esigenze particolari, è molto semplice; infatti MongoDB è auto-contenuto in un archivio compresso. Il server è mongod, che se avviato con successo dà come risultato un output come in Figura 1.

Figura 1 - Il server: mongod.

 

La shell, mongo, accetta comandi in JavaScript, con tutti gli argomenti in formato JSON.

Figura 2 - La shell: mongo.

 

Tutte le funzionalità di amministrazione possono essere fatte con questa console, anche le più complesse come lo sharding e l'impostazione delle repliche.

Replicazione

La replicazione (asincrona) dei dati per gestire il failover può essere configurata in due modalità: Master-Slave e Insiemi di replicazione. Il primo è il metodo più vecchio e meno automatizzato, in cui c'è un master da definire all'avvio e uno o più slave che contengono la replica dei dati; in caso di fallimento del master, il failover deve essere fatto manualmente dall'operatore. Nel metodo degli insiemi di replicazione (Replica Sets), invece, i nodi che appartengono all'insieme eleggono un master in modo autonomo e, in caso di fallimento del master, sono in grado di eleggere un altro master. Per impostare un Replica Set si avviano tre o più server mongod con l'opzione --replSet e quindi si configurano tramite un apposito comando da lanciare nel client. Si rimanda alla documentazione ufficiale per i dettagli.

Auto-Sharding

Lo sharding è il meccanismo con cui MongoDB scala orizzontalmente, ossia partiziona i dati tra più server per bilanciare il carico tra i nodi del cluster. Il bilanciamento viene fatto dinamicamente attraverso un'architettura che prevede tre tipi di nodi:

  • Shard, nodi del cluster che contengono i dati, suddivisi a livello di collezione, in chunk, pezzi di collezione contigui. La suddivisione deve essere abilitata e devono essere specificate una o più shard key, chiavi di frammentazione. Le collezioni, pertanto, saranno ordinate in base alle proprietà specificate nelle shard key e suddivise in chunk.
  • Server di configurazione, che contiene i metadati concernenti il cluster.
  • Router, nodi senza stato che ricevono le connessioni dei client e propagano le richieste ai server appropriati.

Ovviamente, per il client è del tutto indifferente se si trova di fronte a un solo server oppure a un cluster complesso, poiche' tanto il client "parla" solo con il router.

Figura 3 - Architettura Sharding.

 

La creazione di un cluster è un'operazione molto semplice, che può essere fatta persino su una singola macchina, in quattro passaggi:

  1. avviare almeno due server in modalità shard tramite il comando mongod, con l'opzione -shardsvr;
  2. avviare un server di configurazione, sempre con il comando mongod con l'opzione --configsvr;
  3. avviare un server router con il comando mongos, indicando, tramite il comando --configdb, l'indirizzo del server di configurazione;
  4. configurare il cluster, registrando gli shard e le collezioni da suddividere, utilizzando la riga di comando

Questo quarto passaggio, a sua volta, prevede di avviare un client verso il router e di registrare gli shard, utilizzando il comando:

db.runCommand( { addshard : "shardhost" } )

poi la frammentazione delle collezioni deve essere abilitata con il comando:

db.runCommand( { enablesharding : "mydatabase" }

e l'impostazione delle shard key può essere effettuata tramite il comando:

db.runCommand( { shardcollection : "mydatabase.users", key : {name : 1} } )

È inoltre possibile, ed è anche consigliabile, utilizzare come nodo shard un intero Replica Set, in modo da combinare replicazione e bilanciamento, per garantire tolleranza al partizionamento e un alto livello di disponibilità.

Operazioni con la shell

Ora vediamo invece le operazioni che sono più interessanti per lo sviluppatore che vuole conoscere le possibilità di questo database. Per prima cosa, l'oggetto principale con cui si interagisce con MongoDB è db. Per selezionare il database corrente, si usa il comando:

use nome_del_db

Non è necessario che il database sia già stato creato: MongoDB lo creerà automaticamente al primo inserimento. Lo stesso vale per le collezioni. Per riferirsi ad una collezione si usa la notazione:

db.nome_collezione

CRUD

L'inserimento in una collezione avviene tramite la funzione save, ad esempio:

db.users.save( { name : "onof", groups : [ "users", "writers" ], 
             birthdate : new Date(1980, 2, 8) })
db.users.save( { name : "admin", groups : [ "administrators" ], system : true } )

salva i due oggetti nella collezione users. MongoDB assegnerà due valori alla proprietà _id.

Per ottenere l'elenco degli elementi in una collezione è sufficiente invocare:

db.users.find()

Query più complesse si fanno specificando un oggetto che fungerà da pattern per gli oggetti da cercare. Ad esempio:

db.users.find( { system: true } )

restituirà tutti gli oggetti con la proprietà system impostata a true. È prevista una serie di operatori per effettuare query più raffinate e per l'ordinamento:

db.users.find({ $or : [{groups : "administrators"}, 
             {system : {$exists : false}}]}).sort( { name : 1 } )

ricerca tutti gli utenti del gruppo administrators o che non hanno la proprietà system, ordinati per nome. Si rimanda alla documentazione ufficiale per ulteriori approfondimenti sugli operatori. Il comando find permette di fare anche una selezione delle proprietà, ad esempio

db.users.find( { system : true } , { name : 1 } )

non restituisce gli oggetti interi ma solo le proprietà name e _id (viene restituita di default). Inoltre, è possibile effettuare interrogazioni con aggregazione:

db.users.count( { groups : "users" } )

restituisce il conteggio degli utenti nel gruppo users.

L'aggiornamento di un oggetto può essere fatto utilizzando il comando update,

db.users.update({ name : "admin" }, 
             { name : "admin",  groups : [ "administrators" , "system" ] })

dove il primo argomento rappresenta la query, mentre il secondo è il nuovo oggetto;  oppure, molto più semplicemente, con lo stesso comando save, se si conosce la proprietà _id:

db.users.save( { _id : ObjectId("4d7d4621473a000000006598"), 
name : "admin",  groups : [ "administrators" , "system" ] }

La cancellazione può essere fatta con il comando remove, che prende una query come primo argomento; tutti gli oggetti che corrispondono verranno cancellati:

db.users.remove( { name : "onof" } )

Se non si specifica una query, tutta la collezione viene svuotata:

db.users.remove()

MapReduce

Quando è necessario effettuare interrogazioni con aggregazioni complesse o in generale interrogazioni più gravose per quantità di dati coinvolti, MapReduce può risultare molto utile; infatti, il database crea una collezione temporanea per contenere il risultato dell'operazione.

Il processo consta di due passi: nel primo, per ogni elemento della collezione viene chiamata una funzione map, per produrre delle coppie chiave-valore per la successiva funzione, reduce, che aggrega i dati ricevuti e restituisce il risultato. [2]

Ad esempio, supponiamo di avere una collezione di messaggi, ognuno con i suoi tag:

{ message : "testo del messaggio", tags : [ "Java", "MongoDB" ] }

Supponiamo di voler ottenere, per ogni tag presente nella collezione, il numero di messaggi. Ma i messaggi potrebbero essere un numero enorme e sparsi su molti database, quindi piuttosto che interrogare il database a più riprese, e aggregare noi stessi i risultati, è il caso di fare una query MapReduce, prima di tutto perche' è più semplice e naturale da scrivere, ma soprattutto perche' MongoDB è in grado di eseguire in parallelo la parte di mappatura su tutti gli eventuali nodi del sistema e poi di ridurre il risultato in modo molto efficiente. Poiche' vogliamo, nel nostro esempio, una collezione di tag, ognuno con il conteggio, la nostra funzione di mappatura, per ogni messaggio, emetterà, uno alla volta, tutti i tag presenti.

function mapTags() {
      this.tags.forEach(function(t) {
             emit(t, 1);
      });
}

La funzione viene eseguita su ogni documento della collezione, quindi con this si referenzia il singolo documento e deve emettere una coppia chiave valore. Tutte lo coppie emesse vengono quindi aggregate per chiave e passate alla funzione di riduzione, una chiave alla volta. Dunque la funzione di riduzione riceverà in ingresso una chiave e una lista di valori. Per il nostro esempio dunque deve sommare tutti i valori presenti nella lista:

function totalValues(key, values) {
      var total = 0;
      for ( var i=0; i             total += values[i];
      return total;
}

Quindi, dopo aver dichiarato le nostre funzioni nel client, eseguiamo la query:

mrRes = db.messages.mapReduce(mapTags, totalValues)

MongoDB, in mrRes, restituisce non il risultato della query ma un documento JSON contenente informazioni sull'esito dell'operazione. La proprietà result in mrRes contiene il nome della collezione creata con il risultato (comunque tramite un parametro aggiuntivo si può specificare il nome della collezione da creare):

{
             "result" : "tmp.mr.mapreduce_1300279574_6",
             "timeMillis" : 5,
             "counts" : {
                          "input" : 5,
                          "emit" : 8,
                          "output" : 5
             },
             "ok" : 1,
}

La collezione creata è a sua volta interrogabile, quindi:

db[mrRes.result].find()

elencherà i risultati, esempio:

{ "_id" : "Java", "value" : 2 }
{ "_id" : "MongoDB", "value" : 2 }
{ "_id" : "Scala", "value" : 2 }
{ "_id" : "Groovy", "value" : 1 }
{ "_id" : "Cassandra", "value" : 1 }

MongoDB e Java

Le API fornite da MongoDB per Java permettono di fare tutto ciò che è possibile fare da shell. Ogni documento del db è rappresentato da un interfaccia, DBObject. Come vedremo, il più grande svantaggio di queste API è che non sono type-safe. Vediamo un esempio:

import com.mongodb.*;
 
// crea la connessione con il server locale
Mongo db = new Mongo();
 
// apre il db "test". Se non esiste lo crea
DB testDb = db.getDB("test");
 
// prende la collezione "users", se non esiste la crea al primo inserimento
DBCollection collection = testDb.getCollection("users");
 
// INSERIMENTO
// crea un oggetto (in memoria). BasicDBObjectBuilder assiste
// alla creazione di un oggetto
DBObject onof = BasicDBObjectBuilder.start()
                          .add("username", "onof")
                          .add("tags", onofTags)
                          .get();
 
// lo salva nel database
collection.save(onof);
 
// INTERROGAZIONE, per ogni elemento della collezione
// stampa la proprietà "nome"
for(DBObject found : collection.find()) {
      String name = (String) found.get("Name");
      System.out.println(name);
}

Come si vede, i comandi corrispondono esattamente a quelli della shell, visti in precedenza. È quindi consigliabile raggiungere una buona dimestichezza con il client a riga di comando prima di cominciare lo sviluppo, proprio come quando si impara bene il linguaggio SQL prima di passare a JDBC.

Il progetto Morphia [3] fornisce un wrapper type-safe del driver di MongoDB, basato su annotazioni, che può essere molto utile per lavorare in stile più Object Oriented. Ad esempio, supponiamo di voler gestire una collezione di utenti con i relativi messaggi. Iniziamo col definire la nostra entità, utente (per brevità utilizziamo campi pubblici, per non aggiungere getter e setter):

import com.google.code.morphia.annotations.*;
import org.bson.types.*;
 
@Entity(value = "users", noClassnameStored = true)
public class User {
      @Id
      public ObjectId id;
      public String userName;
      public String[] tags;
      @Embedded
      public static class Message {
             public String text;
             public String[] tags;
             public Message() {}
             public Message(String t, String[] tags) {
                   this.text = t;
                   this.tags = tags;
             }
      }
      @Embedded
      public List messages;
}

L'annotazione Entity serve appunto a marcare la classe come entità. Opzionalmente, in value si può specificare il nome della collezione (di default è il nome della classe) e, con noClassnameStored, se il nome della classe deve essere impostato come attributo nel documento. Con Id si identifica il campo che terrà l'identificativo univoco assegnato da MongoDB (proprietà _id), mentre con Embedded marchiamo le classi che rappresentano documenti annidati. A questo punto, lavorare con Morphia è molto semplice:

import com.google.code.morphia.*;
import com.mongodb.*;
 
// connessione a MongoDB locale (e' ancora necessaria l'API di MongoDB)
Mongo db = new Mongo();
 
// il datastore rappresenta il database
Datastore ds = new Morphia().createDatastore(db, "test");
 
// crea due messaggi
ArrayList messages = new ArrayList(2);
 
String[] tags1 = { "MongoDB", "Java" };
messages.add(new User.Message("Hello, Mongo!", tags1));
 
String[] tags2 = { "NoSQL", "MongoDB" };
messages.add(new User.Message("Document-oriented", onofTags));
 
// crea un utente
User onof = new User();
onof.userName = "onof";
onof.tags = tags1;
onof.messages = messages;
 
// lo salva, nella collection degli utenti (users)
ds.save(onof);
 
// cerca l'utente con userName = onof
User found = ds.find(User.class).field("userName").equal("onof").get();
System.out.println("tags found: " + Arrays.toString(onof.tags));

Anche se questo dà l'impressione di lavorare con un ORM, non ha senso parlare di Object-Relational Mapping per un database non relazionale: più precisamente si tratta di serializzazione.

Conclusioni

Al termine della panoramica sulle funzionalità e sulle caratteristiche di MongoDB, dovremmo aver chiare anche le principale problematiche da affrontare nel design e nell'utilizzo di questo database. Molti di questi concetti sono comuni ad altri database NoSQL, particolarmente ai database orientati ai documenti. Per i dettagli sull'utilizzo e sulla configurazione di MongoDB si rimanda sempre alla ricca documentazione ufficiale [1]. Il libro di Kyle Banker [4], nella parte attualmente disponibile, contiene, oltre a un approfondimento degli argomenti qui trattati in questo articolo, molti spunti interessanti e riflessioni. Il blog dell'autore[5], inoltre, è ricco di esempi da "mondo reale". Un'ottima guida è anche il libro di Kristina Chodorow e Michael Dirolf [6], molto approfondito sulla parte dell'amministrazione del database. Per chi è interessato proprio allo sharding e in generale alla scalabilità, Kristina Chodorow ha di recente pubblicato un libro su questo argomento [7].

Riferimenti

[1] MongoDB

http://www.mongodb.org/

 

[2] Google Code University, "Introduction to Parallel Programming and MapReduce"

http://code.google.com/intl/it-IT/edu/parallel/mapreduce-tutorial.html#MapReduce

 

[3] morphia. A type-safe java library for MongoDB

http://code.google.com/p/morphia/

 

[4] Kyle Banker, "MongoDB in Action", Manning Publications Co.

http://www.manning.com/banker/

 

[5] hwæt! (Kyle Banker's blog)

http://kylebanker.com/blog/

 

[6] Kristina Chodorow, Michael Dirolf, "MongoDB, the Definitive Guide", O'Reilly, 2010

 

[7] Kristina Chodorow, "Scaling MongoDB", O'Reilly, 2011

 

Condividi

Pubblicato nel numero
161 aprile 2011
Onofrio Panzarino, ingegnere elettronico, lavora ad Ancona come software architect, per Wolters Kluwer Italia. Sviluppatore con esperienza in vari linguaggi e piattaforme, soprattutto web-oriented, è molto interessato a soluzioni scalabili e a linguaggi di programmazione funzionali. È speaker in JUG Marche su argomenti correlati a Scala e database NoSQL.
Articoli nella stessa serie
Ti potrebbe interessare anche