MongoDB – II Deo

Upite koje smo do sada razmatrali u prvom delu jesu korisni za osnovni CRUD (Create, Read, Update, Delete) nivo upotrebe, ali bilo kakvu naknadnu obradu podataka bismo morali samostalno da izvodimo. Na sreću, MongoDB baterija alata se ne završava na jednostavnom CRUD-u, već su tu alati koji omogućavaju agregaciju, paralelni rad, rukovanje geo-koordinatama i još puno toga. Hajdemo redom.

Agregatni upiti

Na primer, ako želimo da prebrojimo sve telefonske brojeve brojčano veće od 559-9999, koristićemo serversku komandu count():

db.phones.count({'components.number': { $gt : 5599999 } })
50000

Kako bismo pokazali ostale mogućnosti agregatnih upita potrebno je da dodamo još 100,000 telefonskih brojeva. Koristićemo istu funkciju koju smo već kreirali, samo ovoga puta sa drugim identifikatorom za oblast.

populatePhones( 855, 5550000, 5650000 )

Funkcija distinct(), slično kao kod standardnog SQL-a vraća svaku podudarajuću vrednost koja se javlja jednom ili više puta:

db.phones.distinct('components.number', {'components.number': { $lt : 5550005 } })
[ 5550000, 5550001, 5550002, 5550003, 5550004 ]

Iako postoje dva broja 5550000 (jedan za oblast 800 i jedan za 855), u rezultatu se ovaj broj pojavljuje samo jednom.

Agregatni upit group() je jako sličan GROUP BY-u u SQL-u. To je takođe i najkompleksniji pojedinačni upit u Mongo-u. Kao primer možemo da pronađemo sve telefonske brojeve brojčano veće od 5,559,999 i da ih grupišemo prema tome kojoj oblasti pripadaju:

db.phones.group({
    initial: { count:0 },
    reduce: function(phone, output) { output.count++; },
    cond: { 'components.number': { $gt : 5599999 } },
    key: { 'components.area' : true }
})
[ { "800" : 50000, "855" : 50000 } ]

Key je polje po kome želimo da izvršimo grupisanje, cond je uslov kojim određujemo koje vrednosti posmatramo, reduce je funkcija koja određuje kako će izlaz zavisiti od vrednosti. Bez problema možemo da kreiramo count funkciju sa funkcijom group, izostavićemo argument key i na taj način izbeći ikakvo grupisanje:

db.phones.group({
    initial: { count:0 },
    reduce: function(phone, output) { output.count++; },
    cond: { 'components.number': { $gt : 5599999 } }
})
[ { "count" : 100000 } ]

Funkcija group() jeste moćan alat, ali ima svoje mane kao što je maksimalan broj dokumenata od 10000. Takođe, ako delite Mongo kolekciju, što ćemo uraditi u nastavku, group neće raditi. Pristupačnija rešenja ćemo razmatrati malo kasnije u okviru mapreduce sekcije ali pre toga moramo da obratimo pažnju na komande na serveru.

Prosleđivanje funkcija serveru

Kada bismo sledeću funkciju pokrenuli u okviru komandne linije kao klijent, povlačili bismo svaki od brojeva jedan po jedan sa servera, svih 100,000, i čuvali ih opet jedan po jedan na serveru:

mongo/update_area.js
update_area = function() {
    db.phones.find().forEach(
        function(phone) {
            phone.components.area++;
            phone.display = "+"+
            phone.components.country+" "+
            phone.components.area+"-"+
            phone.components.number;
            db.phone.update({ _id : phone._id }, phone, false);
        }
    )
}

MongoDB nam omogućava da funkciju prosledimo serveru na izvršavanje, što drastično umanjuje komunikaciju između servera i klijenta, a funkcija koja omogućava ovo jeste eval():

> db.eval(update_area)

Postoji još par ugrađenih funkcija u MongoDB-u koje se izvršavaju na serveru, a neke od njih zahtevaju izvršavanje nad admin bazom:

> use admin
> db.runCommand("top")

Komanda top će nam prikazati detalje o svim kolekcijama na serveru:

> use book
> db.listCommands()

Pokretanjem komande listCommands možete primetiti mnoge komande koje smo već koristili. Mnoge naredbe možemo izvršiti komandom runCommand(), npr. brojanje telefonskih brojeva:

> db.runCommand({ "count" : "phones" })
{ "n" : 100000, "ok" : 1 }

Broj n označava koliko ima telefonskih brojeva, dok je ok indikator da je izvršavanje komande prošlo kako treba.

Bilo koju JavaScript funkciju možemo da sačuvamo na serveru (nešto slično stornoj proceduri u SQL-u) u specijalnoj kolekciji system.js. Funkciju čuvamo tako što polje _id predstavlja naziv funkcije, dok je polje value sadrži telo funkcije:

> db.system.js.save({
    _id:'getLast',
    value:function(collection){
        return collection.find({}).sort({'_id':1}).limit(1)[0]
    }
})

Kreiranu funkciju možemo pozvati na serveru komandom eval():

> db.eval('getLast(db.phones)')

RunCommand

Ako pogledamo implementaciju funkcije runCommand(), možemo videti da je i ona takođe pomoćna funkcija koja se obraća kolekciji pod nazivom $cmd:

> db.runCommand
function (obj) {
    if (typeof obj == "string") {
        var n = {};
        n[obj] = 1;
        obj = n;
    }
    return this.getCollection("$cmd").findOne(obj);
}

Zapravo možete izvršiti svaku komandu direktnim pozivom ove kolekcije:

> db.$cmd.findOne({'count' : 'phones'})
{ "n" : 100000, "ok" : 1 }

MapReduce

MapReduce u Mongo-u je sličan standardom Hadoop/Spark pristupu, sa nekim sitnim razlikama. Maper funkcija u Mongu poziva emit() funkciju sa određenim ključem, a pogodnost ovakvog pristupa jeste ta što možemo emitovati više puta za isti dokument. Funkcija redukcije reduce() prihvata i listu vrednosti koje su emitovane pod tim ključem. Mongo nudi i treći korak, funkciju finalize() koja se izvršava samo jednom po mapiranoj vrednosti nakon izvršavanja reduce() funkcije.

Mapreduce ćemo demonstrirati na primeru generisanja izveštaja koji broji sve telefonske brojeve koji se sastoje od istih cifara za svaku državu posebno. Prvo ćemo generisati funkciju koja za zadati telefonski broj vraća niz cifara koje on sadrži u sebi:

mongo/distinct_digits.js
distinctDigits = function(phone){
    var number = phone.components.number + '', seen = [], result = [], i = number.length;
    while(i--) {
        seen[+number[i]] = 1;
    }

    for (i=0; i<10; i++) {
        if (seen[i]) {
            result[result.length] = i;
        }
    }
    return result;
}
db.system.js.save({_id: 'distinctDigits', value: distinctDigits})

Funkciju možemo testirati pre nego što nastavimo:

db.eval("distinctDigits(db.phones.findOne({ 'components.number' : 5551213 }))")
[ 1, 2, 3, 5 ]

Sada možemo da kreiramo funkcije map() i reduce(). Pošto želimo da pronađemo brojeve sa različitim ciframa, niz različitih cifara će biti prvo polje u funkciji map(). Pošto istovremeno želimo da ih razdvojimo i po državama, drugo polje će nam predstavljati državu. Ključ nam dobija sledeći izgled {digits : X, country : Y}. Naš cilj jeste da prebrojimo ove brojeve tako da ćemo emitovati vrednost 1, pošto jedan dokument predstavlja jedan telefonski broj:

mongo/map_1.js
map = function() {
    var digits = distinctDigits(this);
    emit({digits : digits, country : this.components.country}, {count : 1});
}

Posao reduce funkcije jeste da sumira sve emitovane jedinice:

mongo/reduce_1.js
reduce = function(key, values) {
    var total = 0;
    for(var i=0; i<values.length; i++) {
        total += values[i].count;
    }
    return { count : total };
}

results = db.runCommand({
    mapReduce: 'phones',
    map: map,
    reduce: reduce,
    out: 'phones.report'
})

Pošto smo parametar out postavili na phones.report rezultat treba potražiti u tom dokumentu:

> db.phones.report.find({'_id.country' : 8})
{
    "_id" : { "digits" : [ 0, 1, 2, 3, 4, 5, 6 ], "country" : 8 },
    "value" : { "count" : 19 }
}
{
    "_id" : { "digits" : [ 0, 1, 2, 3, 5 ], "country" : 8 },
    "value" : { "count" : 3 }
}
{
    "_id" : { "digits" : [ 0, 1, 2, 3, 5, 6 ], "country" : 8 },
    "value" : { "count" : 48 }
}
{
    "_id" : { "digits" : [ 0, 1, 2, 3, 5, 6, 7 ], "country" : 8 },
    "value" : { "count" : 12 }
}
has more

Ulaz jedne reduce() funkcije može biti rezultat map() funkcije, ali pored toga može biti i rezultat druge reduce() funkcije. Ova mogućnost postoji iz razloga što funkcije možemo izvršavati na različitim serverima. Svaki server pokreće svoje map() i reduce() funkcije i onda šalje rezultate na spajanje servisu koji je započeo izvršavanje. Ukoliko bismo promenili count u total, morali bismo to da rešimo u reduce() funkciji:

mongo/reduce_2.js
reduce = function(key, values) {
    var total = 0;
    for(var i=0; i<values.length; i++) {
        var data = values[i];
        if('total' in data) {
            total += data.total;
        } else {
            total += data.count;
        }
    }
    return { total : total };
}
MongoDB mapreduce
Mongo mapreduce na dva servera

Replicirajući setovi

Mongo je zamišljen da radi na više jedinica (mašina) a ne na pojedinačnoj. Napravljen je da omogući konzistentnost podataka, kao i da bude prilagodljiv na podele. Međutim, podela podataka ima svoju cenu, tj. ako je jedan deo kolekcije izgubljen svi podaci padaju u vodu. Mongo ovde primenjuje vrlo jednostavno rešenje – dupliranje. Kako bismo demonstrirali dupliranje, napravićemo potpuno nove instance servera kao i podatke. Pre svega, potrebno je da kreiramo nove direktorijume za nove servere:

$ mkdir ./mongo1 ./mongo2./mongo3

Standardni port za Mongo server je 27017, ali ovoga puta pri kreiranju servera ćemo zadati nove portove. Takođe ćemo zadati i -replSet sa vrednošću book. Dodaćemo i -rest zastavicu kako bismo uključili web interfejs. Svaku od sledećih komandi pokrećemo u zasebnoj komandnoj liniji.

$ mongod --replSet book --dbpath ./mongo1 --port 27011 --rest
$ mongod --replSet book --dbpath ./mongo2 --port 27012 --rest
$ mongod --replSet book --dbpath ./mongo3 --port 27013 --rest

Sada ćemo se povezati na neki od Mongo servera kako bismo pokrenuli inicijalizaciju:

$ mongo localhost:27011
> rs.initiate({
    _id: 'book',
    members: [
        {_id: 1, host: 'localhost:27011'},
        {_id: 2, host: 'localhost:27012'},
        {_id: 3, host: 'localhost:27013'}
    ]
})
> rs.status()

Možete primetiti da koristimo objekat rs koji zapravo označava da je ovo replicirajući set, a pozivom funkcije help možete videti detaljnije njegove osobine. Pozivom funkcije status() imaćemo uvid u status servera. Na izlazima prethodno pokrenutih servera možemo videti da se na jednom pojavljuje linija [rs Manager] replSet PRIMARY, dok se na ostala dva pojavljuje [rs_sync] replSet SECONDARY. Primarni čvor će biti glavni server, pa u novoj komandnoj liniji pristupite ovom serveru i pokrenite sledeću komandu:

> db.echo.insert({ say : 'HELLO!' })

Nakon dodavanja napustite konzolu i ugasite glavni server kako bismo proverili da li su novi podaci sačuvani. Ako posmatrate komandne linije ostala dva servera primetićete da je jedan od njih sada postao glavni. Konektujte se na novi glavni server i pokrenite komandu db.echo.find(), kako bi proverili postojanje novododatih podataka. Sada se povežite na server koji nije glavni već sporedni, i probajte da dodate podatak:

$ mongo localhost:27013
> db.isMaster()
{
    "setName" : "book",
    "ismaster" : false,
    "secondary" : true,
    "hosts" : [
        "localhost:27013",
        "localhost:27012",
        "localhost:27011"
    ],
    "primary" : "localhost:27012",
    "ok" : 1
}

> db.echo.insert({ say : 'is this thing on?' })
not master

Poruka not master nam stavlja do znanja da smo na sekundarnom serveru i da sa njega ne možemo da menjamo stanje baze.

Repliciranje podataka implicira dosta problema, a jedan od njih jeste izbor novog glavnog servera kada glavni server padne. Mongo rešava ovaj problem tako što svakom serveru pruža priliku, a server koji ima najsvežije podatke biva proglašen za novi glavni server. Sada probajte da ugasite vaš glavni server i jedan od preostala dva. Očekivani sled događaja jeste da preostali server postane glavni, ali se to ne dešava. Izlaz na preostalom serveru bi trebao da izgleda slično ovome:

[ReplSetHealthPollTask] replSet info localhost:27012 is now down (or...
[rs Manager] replSet can't see a majority, will not try to elect self

Sada ponovo pokrenite servere. Kao što možete primetiti, on će pokušati da povuče izmene sa novog glavnog servera kako bi usaglasili stanja. Ako se pitate šta se dešava sa izmenama koje prethodni glavni server nije uspeo da upiše, odgovor je da se on brišu.

Problem sa parnim brojem čvorova

Pored toga što server može da padne i sama mreža između servera može da padne. Mongo nalaže da novu mrežu čini ona grupa koja ima većinu preostalih čvorova. Zbog ovoga Mongo očekuje da replicirajućih servera ima neparan broj. Zamislite mrežu od pet servera, i podelu u dve grupe od dva i tri čvora. Grupa od tri čvora očigledno ima prednost zbog toga što je veća, i ona će nastaviti sa opsluživanjem. Ukoliko broj čvorova nije neparan, ustanoviti prednost može ponekada biti nemoguće, što nije dobro, tako da je neparan broj servera neophodnost.

Particionisanje

Jedan od glavnih razloga za postojanje Mongo-a jeste brzo i sigurno skladištenje velike količine podataka. Najbolji način za postizanje ovog cilja jeste horizontalno particionisanje po rasponu vrednosti. Umesto da svi podaci stoje na jednom serveru, vrednosti su podeljene u više raspona i čuvaju se na različitim serverima. Na primer kolekcija telefonskih brojeva koju smo ranije koristili bi mogla da bude podeljena na dva servera A i B. Server A bi čuvao vrednosti manje od 1-500-000-0000, a server B preostale. Mongo ovo radi automatski umesto korisnika.

Da bi smo demonstrirali particionisanje podataka moraćemo da kreiramo još nekoliko servera:

$ mkdir ./mongo4 ./mongo5
$ mongod --shardsvr --dbpath ./mongo4 --port 27014
$ mongod --shardsvr --dbpath ./mongo5 --port 27015

Kako bismo pratili podatke na serverima, potrebno je kreiramo konfiguracioni server:

$ mkdir ./mongoconfig
$ mongod --configsvr --dbpath ./mongoconfig --port 27016

Sada je potrebno da kreiramo četvrti server koji se zove mongos, i koji će zapravo biti pristupna tačka za klijente. On će biti povezan sa konfiguracionim serverom, kako bi znao gde se koji podaci nalaze. Pokrenućemo ga na portu 27020:

$ mongos --configdb localhost:27016 --port 27020

Mongos server je zapravo mongod u malom, i predstavlja odličan način da klijenti pristupe particionisanim serverima.

Particionisani klaster
Particionisani klaster

Sada možemo da pristupimo mongos serveru kako bismo konfigurisali pristup shared serverima. Da bismo ovo uradili moramo pristupiti admin bazi.

$ mongo localhost:27020/admin
> db.runCommand( { addshard : "localhost:27014" } )
{ "shardAdded" : "shard0000", "ok" : 1 }
> db.runCommand( { addshard : "localhost:27015" } )
{ "shardAdded" : "shard0001", "ok" : 1 }

Sada je ostalo još da ubacimo podatke, za šta možemo iskoristiti telefonske brojeve.

> db.runCommand( { enablesharding : "test" } )
{ "ok" : 1 }
> db.runCommand( { shardcollection : "test.phones", key : {display : 1} } )
{ "collectionsharded" : "test.phones", "ok" : 1 }

Geo-prostorni upiti

Mongo ima mogućnost da brzo i jednostavno izvršava geo-prostorne upite. Snaga ove pretrage leži u indeksima. To su specijalni indeksi za geografske podatke koji se zovu geohash, koji ne samo da mogu da pronađu vrednost u nekom opsegu, već mogu i da reše problem najbližeg suseda u trenutku.

Ukoliko pretpostavimo da naša kolekcija gradova ima sve gradove na svetu sa poljem location, koje predstavlja lokaciju izraženu pomoću geografske širine i dužine, podizanje indeksa bi izgledalo ovako:

> db.cities.ensureIndex({ location : "2d" })

Sada možemo da pronađemo najbliže gradove nekoj tački na planeti:

> db.cities.find({ location : { $near : [45.52, -122.67] } }).limit(5)

GridFS

Jedna od mana distribuiranih sistema jeste nedostatak jedinstvenog koherentnog fajl sistema. Pretpostavimo da imamo web sajt na kome korisnici mogu da dodaju svoje slike. Ako imamo više web servera na više čvorova, morali bi smo ručno da prebacujemo dodate slike na svaki od servera. Mongo ovo rešava svojim distribuiranim fajl sistemom koji se zove GridFS.

Mongo ima ugrađen alat komande linije za upravljanje GridFS-om. GridFS nije potrebno dodatno konfigurisati, već je spreman za upotrebu odmah po instalaciji. Možemo da izlistamo fajlove na serveru mongos koji smo kreirali na portu 27020, ali rezultata neće biti pošto još uvek nema nikakvih fajlova:

$ mongofiles -h localhost:27020 list
connected to: localhost:27020

Sada možemo da dodamo neki fajl:

$ mongofiles -h localhost:27020 put my_file.txt
connected to: localhost:27020
added file: { _id: ObjectId('4d81cc96939936015f974859'), filename: "my_file.txt", \
 chunkSize: 262144, uploadDate: new Date(1300352150507), \
 md5: "844ab0d45e3bded0d48c2e77ed4f3b0e", length: 3067 }
done!

Zatim možemo ponovo da izlistamo fajlove kako bismo videli da li smo uspešno dodali fajl:

$ mongofiles -h localhost:27020 list
connected to: localhost:27020
my_file.txt 3067

Zaključak

Nadamo se da je demonstracija u ovom članku pomogla da shvatite šta je Mongo, koje su njegove mogućnosti i kada ga treba koristiti. Veliki broj osnovnih pojmova je obrađen, ali naravno, samo smo zagrebali po površini. Glavna prednost Mongo-a jeste u njegovoj jednostavnoj upotrebi, kao i sposobnosti da brzo obradi veliku količinu podataka repliciranjem i horizontalnim skaliranjem. Takođe omogućava i veoma fleksibilan model podataka, a dodavanje polja u dokumentima je jednostavno poput dodavanja podataka u relacionim modelima. Mongo se često nameće kao mnogo prirodnije rešenje od relacionih baza za određenu grupu problema.