Lezione 5 – Peer to Peer ( P2P )

Troviamo un esempio di applicazione modellata seguendo la teoria dei sistemi complessi nelle reti PEER TO PEER, anche dette reti P2P.

Tali reti vengono così denominate perché i nodi (detti peer) che le compongono dovrebbero essere, almeno idealmente, tra loro equivalenti e quindi avere ruoli identici (o simili) nelle funzioni di gestione del sistema.

Per funzioni si intendono:
1 - SCAMIO RISORSE: è questa la funzione di base, riguarda il trasferimento di file da parte di un peer verso un altro peer.
2 - // BOOT o LOGIN : questa è la fase in cui un nodo va a collegarsi alla rete, fa sapere a tutti gli altri nodi che esiste, che è collegato e dove si trova.
3 -
LOOKUP o ROOTING: le risposte alle query e il loro instradamento.
4 -
CARICO di LAVORO: il carico di lavoro, espresso in termini computazionali, che viene attribuito ai singoli peer.
5 -
RESPONSABILITA’ LEGALE riguardo ai CONTENUTI//: fondamentale per le reti P2P.

Seppure questi siano aspetti comuni a tutte le reti P2P, non tutte danno pari importanza ai nodi che le compongono nello svolgimento di ognuna di queste funzioni, anzi è molto più facile incontrare reti solo parzialmente P2P, in cui i nodi sono equivalenti, ed hanno quindi pari importanza, solo per alcune funzioni.

In generale, per P2P si intende un sistema con queste caratteristiche:
• Decentralizzato.
• Auto – organizzato.
• In cui i nodi sono indipendenti e autonomi nello svolgimento di ogni funzione.
Tuttavia, spesso tutto questo si riduce ad avere nodi con sia funzione di client che di server.
Possiamo infatti riscontrare che nei P2P più tradizionali e di maggior successo sono pochissime le funzioni che si possono definire completamente P2P.

P2P Tradizionali

Esamineremo qui di seguito alcune tra le reti P2P che si sono maggiormente diffuse e che hanno caratterizzato lo sviluppo del P2P.
Le analizzeremo prendendo in esame le tre fasi che caratterizzano il classico utilizzo delle rete P2P.

Napster

Un primo esempio di rete P2P tradizionale lo vediamo con Napster.

BOOT: booting centralizzato perché avviene autenticandosi al server centrale di Napster. Al momento dell’autenticazione viene assegnato ad ogni nodo un codice con cui il server lo identificherà all’interno della rete.
LOOKUP: ogni nodo di Napster che vuole inoltrare una richiesta ad un altro nodo invia una query al server centrale. Il server centrale, a seguito della fase di boot ha una lista di tutti i nodi connessi alla rete e delle loro risorse.
SCAMBIO RISORSE: il server centrale di Napster, a seguito della ricezione di una query da parte di un nodo, mette in contatto il nodo richiedente con il nodo cercato; da qui in poi il trasferimento di risorse avviene solo tra i due nodi interessati, senza ulteriori coinvolgimenti del server centrale.
Solo la fase di scambio risorse è realmente P2P.

Problemi:
L’architettura centralizzata di Napster ha causato gravi problemi di “single-point-of-handling” ovvero, essendo il server uno solo e venendo utilizzato in tutte le fasi di boot e lookup di ogni nodo, esaurisce rapidamente le sue risorse e la sua larghezza di banda, causando di conseguenza dei colli di bottiglia che impediscono lo scambio di risorse tra i nodi.
Più gravi furono i problemi di natura legale, tanto che ne causarono la chiusura. Dato che Napster manteneva una lista dei nodi connessi, fu ritenuto legalmente responsabile degli scambi di materiale che avvenivano tra i suoi utenti, e quindi quando questi scambiandosi i file violarono i diritti d’autore o altre leggi, Napster ne fu ritenuto responsabile.

Gnutella

Col fallimento di Napster si sono diffusi altri tipi di protocolli per reti P2P, tra i quali i più diffusi furono quelli della famiglia di Gnutella.

BOOT: in Gnutella il booting può avvenire in due modi: autenticandosi a un server centrale, nel qual caso il booting è centralizzato, o pingando la sua rete fino a che un nodo già connesso a Gnutella non gli concederà il proprio identificativo. In tal modo il nodo che voleva connettersi entra nella rete considerando come vicino il nodo che gli ha risposto ed è a questo che il nuovo nodo invierà le proprie query, e sempre da questo otterrà il listato dei suoi nodi vicini che diventeranno a loro volta potenziali vicini per il nodo appena connesso.
La fase di boot varia da versione a versione, comunque la possiamo definire abbastanza P2P.
LOOKUP: avviene per mezzo di un processo di flooding limitato da un flag di TTL. In Gnutella il meccanismo di rooting è particolare e viene chiamato flooding in quanto si basa sulla diffusione a raggio delle query (vedi immagine sotto), che vuol dire che ogni nodo che riceve una query la passa a tutti i suoi vicini, che faranno lo stesso e così via. In tal modo il numero di risorse raggiungibili dipende solamente da due fattori: dal numero medio di vicini di ogni nodo della rete e da quanti passaggi verranno fatti. E’ anche chiaro che questo tipo di protocollo produce degli overhead, ovvero carichi di lavoro non richiesti, perché un nodo potrà ricevere più volte la stessa query passatagli da diversi vicini. Per rendere questo protocollo scalabile, e impedire che la propagazione della query nei nodi della rete segua un andamento esponenziale, viene introdotto il flag di TTL (Time To Live), ovvero un contatore posto sulla query che raggiunto un valore prefissato comporta l’interruzione del flooding di quella query.
Questa fase è completamente P2P proprio perché avviene solo tra i nodi della rete.
SCAMBIO RISORSE: dopo aver aperto una connessione nodo a nodo, nella fase di lookup, avviene lo scambio di risorse direttamente tra i due nodi che è quindi P2P.

Immagine1.jpg

Gnutella è il più semplice protocollo di rete che sviluppi la filosofia P2P in tutte le sue componenti.

Problemi:
Gnutella soffre principalmente di due problemi di natura tecnica dovuti alla sua struttura: scarsa scalabilità perché all’aumentare del numero delle query e della dimensione della rete il protocollo di flooding crea un overhead che è sempre maggiore e alta probabilità di non ottenere risposte perché è possibile che il meccanismo di flooding non vada a raggiungere il nodo contenente la risorsa cercata.

Di fatto i protocolli oggi più utilizzati nelle reti P2P sono una versione avanzata di Gnutella con l’aggiunta della nozione di supernodo. Per supernodi si intendono quei nodi che possiedono una visione abbastanza ampia della rete e che vengono tipicamente utilizzati per la fase di connessione.
Un tipico esempio di queste reti sono Kazaa o Skype nelle quali, non appena viene individuata una macchina con sufficienti capacità, computazionali e di banda, la si eleva a supernodo, e per quel nodo passeranno le comunicazioni di diversi altri nodi.
Questo meccanismo viene utilizzato nell’intento di equilibrare scalabilità e prestazioni ed è oggi il sistema più utilizzato per le reti P2P, seppure sia un’ibridazione tra filosofie P2P e filosofie non P2P.

DHT (Distribuited Hash Tabels)

Nel 2003 fecero la loro comparsa i protocolli della famiglia Hash Tabels e cioè protocolli che indicizzano le risorse della propria rete per mezzo delle hash tabels, che sono tabelle contenenti log(N) nodi, dove N è il numero di nodi della rete.

Chord

Una visione generale del protocollo DHT la otteniamo esaminando Chord.

BOOT: i nodi che vogliono connettersi ottengono una collocazione all’interno di uno spazio continuo che è descritto dalle hash tabels; i nodi così connessi acquisiscono le risorse attigue a quello spazio.
LOOKUP: avviene per mezzo delle hash tabels, aggiornate all’ultimo booting. Grazie a questo sistema si ottengono risposte alle query in tempo logaritmico.
SCAMBIO RISORSE: avviene aprendo una connessione nodo a nodo.

Le DHT nacquero con lo scopo di risolvere i 4 problemi principali che avevano fino ad allora afflitto le reti P2P:
• Decentralizzazione
• Raggiungibilità
• Scalabilità
• Carico Bilanciato
Per decentralizzazione si intende che tutti i nodi della rete devono avere le stesse funzioni e la stessa importanza, e di conseguenza non possono esistere superpeer, come ad esempio i server centrali.
Tuttavia la decentralizzazione non deve andare a ridurre la raggiungibilità delle risorse ovvero le risorse devono essere reperibili in qualsiasi momento. Il problema di mettere insieme decentralizzazione e massima raggiungibilità sta nel fatto che, essendo la rete in continua evoluzione a causa di nodi che si collegano e scollegano, le risorse appartenenti a nodi attualmente scollegati non sono momentaneamente raggiungibili.
Altro problema che i progettisti delle DHT si prefiggevano di risolvere era quello della scalabilità e cioè al crescere delle dimensioni della rete, il tempo di elaborazione delle operazioni fondamentali deve crescere in maniera contenuta, seguendo un andamento logaritmico.
E infine volevano mantenere un carico di lavoro bilanciato, ovvero il numero di query deve esser proporzionato al numero di risorse mantenute.
Le DHT si fondano sulle hash tabels proprio per risolvere questi problemi.
In pratica, ogni nodo (gli host) e risorsa (i files) viene mappato sulle hash tabels, attribuendo ad ognuno un numero univoco detto chiave.
La chiave è generata eseguendo una funzione di hash sul nome del file o dell’IP del nodo.

Immagine2.jpg

Tramite questi numeri è possibile collocare ognuno di loro in uno spazio continuo, e questo facilita l’implementazione e la gestione della funzione di prossimità che ci permette di passare da un indice al suo successore.
Dopodiché vengono attribuite le responsabilità delle risorse ai nodi, associando ad ogni nodo le chiavi delle risorse che gli sono collegate, ed in quanto responsabile di quelle risorse il nodo verrà contattato da chiunque voglia accedere a quei files.
Quindi quando un nodo richiede l’uso di una risorsa, come ad esempio il trasferimento di un file, l’unica cosa che il DHT deve fare è fornire una funzione di lookup(key) che restituisca l’identità del nodo responsabile di quella risorsa, risorsa che viene identificata grazie alla sua chiave (key).
Nel caso in cui un nodo responsabile di alcune risorse si disconnetta dalla rete, dovrà passare tutte le sue risorse al nodo a lui più prossimo secondo le hash tabels, così da garantirne la raggiungibilità anche in sua assenza.
Le hash tabels permettono quindi l’ordinamento delle risorse, così da poterle considerare come elementi di uno spazio continuo, con una conseguente semplificazione di tutte le operazioni sopra descritte.

Le hash tabels, al loro interno, sono organizzate come degli array circolari su cui vengono mappate le chiavi, ognuna delle quali ha 2 identificatori: successor(k) e k. k è la chiave di una determinata risorsa, e successor(k) è il responsabile di quella risorsa, ed è il primo nodo che succede in senso orario la chiave di quella risorsa.

New_Immagine3.jpg

Nell’immagine sopra viene mostrata l’organizzazione di una hash tabel: in verde troviamo i nodi responsabili delle risorse e in nero le risorse. In questo esempio il nodo 3 è responsabile della risorsa 2, in quanto primo nodo successore, e per lo stesso motivo le risorse da 4 a 7 saranno sotto la responsabilità del nodo 0.
E’ chiaro che al crescere delle dimensioni della rete aumenteranno sia i nodi che le risorse, il che comporta una crescita proporzionale delle chiavi contenute nella hash tabel. Dato che ogni nodo conosce solo i nodi a lui vicini, durante la fase di lookup, la ricerca di una risorsa richiesta da una query, richiederà la visita dell’intera hash tabel. Si avrà quindi un notevole spreco di tempo che cresce col crescere delle dimensioni della rete.
Bisogna quindi ottimizzare il processo di lookup per diminuire i tempi di ricerca e soprattutto per renderlo più scalabile.
La soluzione al problema la si ha aggiungendo informazione ovvero aumentando il numero di vicini conosciuti da ogni nodo, il che vuol dire che i nodi oltre a conoscere i nodi a loro adiacenti devono conoscere anche qualche nodo lontano, così da poter coprire l’intera rete. Questa idea rilrre l’idea del modello dei legami deboli dove il fatto di avere dei collegamenti deboli ci permette di avere nuove conoscenze.
Quindi, invece di avere per ogni nodo un puntatore al suo successore, avremo per ogni nodo una “finger table” e cioè una tabella su due colonne con come primo campo un indice contenente alcuni nodi della rete, ordinati per distanza dal nodo attuale (dal più vicino al più distante), e come secondo campo il nodo del più alto predecessore a cui saltare per avvicinarsi alla risorsa cercata.
Nel momento in cui viene inoltrata un richiesta, il nodo consulta la sua finger table: in base alla chiave della risorsa determina a che indice essa appartiene e di conseguenza sa anche a che nodo collegarsi per avvicinarsi alla risorsa e vi si collega. Qui l’operazione si ripete fino a che, avvicinandosi sempre più, arriva al nodo responsabile della risorsa da noi cercata.

Immagine4.jpg

Esempio: se si è nel nodo N8 e si ha una richiesta per la chiave 50, che non compare nella finger table di N8, la richiesta sulla chiave 50 viene inoltrata al nodo N42 in quanto nodo più vicino a quella chiave, tra i conosciuti di N8. In N42 la procedura si ripete consultando la sua finger table, e così via fino a giungere, per avvicinamenti successivi, al nodo responsabile della chiave 50.

Nelle finger tabels ogni posizione i dell’indice contiene il nodo successore delle chiavi N + 2*(i-1), inoltre dato che le finger tables contengono solo gli indirizzi di k nodi, permettono ad ogni nodo di avere solo una visione parziale della rete.
Ovviamente perché tutto funzioni avremo bisogno di finger tabels continuamente aggiornate che tengano conto dell’evoluzione della rete, e ogni nodo che si connette dovrà generare la propria finger tabel. Per mezzo di questo sistema otteniamo un processo di lookup più efficiente, riuscendo a bilanciare la dimensione delle tabelle di rooting e i messaggi necessari per ritrovare la chiave.

New_Immagine5.jpg






Con le DHT riusciamo così ad ottenere una via di mezzo tra la struttura ad anello, dove avevamo massima raggiungibilità senza bisogno di conoscere la topologia della rete ma a scapito delle prestazioni, e i grafi fortemente connessi in cui le elevate prestazioni erano condizionate ad una ampia conoscenza della topologia della rete.

Tuttavia persiste un problema che pesa notevolmente sull’efficienza e l’adattabilità di questo protocollo: dovendo garantire che tutte le risorse siano sempre reperibili, al momento della disconnessione di un host tutte le sue risorse dovranno essere passate a un altro host, e questo causa un evidente e continuo overhead.
Quindi questo protocollo si dimostra utile per applicazioni ben specifiche quali hard disk distribuiti o sistemi DNS in cui la raggiungibilità ha un ruolo più rilevante dell’efficienza, ma non trova applicazione in sistemi che richiedono un grado di prestazioni superiore.
Inoltre, seppure siano stati risolti i problemi di scalabilità, di raggiungibilità, e di bilanciamento del carico, persiste il problema della non-autonomia e non-equivalenza dei nodi, che è uno dei punti portanti della filosofia P2P. La causa di questa violazione risiede nel fatto che non viene permessa ai nodi la gestione autonoma delle risorse, che invece vengono assegnate d’autorità, e questo produce a cascata anche i problemi di violazione della privacy e della proprietà intellettuale, oltre ai problemi di overhead per la gestione dello scambio che sono stati discussi prima.

Concludendo si può dire che l’insieme di questi problemi rende tale tipo di protocolli non effettivamente implementabili o implementabili solo in casi troppo specifici.

Nuove direzioni

Quindi la ricerca sulle reti P2P, ha per il momento accantonato l’array circolare per esplorare nuovi protocolli che si basino sulle teorie delle reti sociali e dei sistemi complessi, al fine di sfruttare le leggi statistiche su cui si fondano queste teorie.
I principali approcci in questo senso partono dall’idea che la propagazione delle query (il lookup) possa essere effettuata attraverso dei meccanismi di flooding (come in Gnutella) ma probabilistici, sfruttando le teorie su grafi causali per calcolare dei punti di ottimalità per l’interconnessione tra i nodi così da consentire la comunicazione lungo tutta la rete.
In tal modo troviamo delle ottimalità che si discostano da quelle ottenute con il flooding “classico” di Gnutella, che si basa sull’inoltro delle query fra nodi tra loro vicini.

Percolation Theory

La Percolation Theory, seppure nasca in campo fisico, trova corrispondenza con la teoria di Erdos e Renyi sulla costruzione dei grafi casuali.
Scopo di tale teoria è definire il punto critico di un sistema, e cioè il punto in cui avviene un passaggio di fase.

New_New_Immagine6.jpg



In teoria delle reti, possiamo rappresentare graficamente la Percolation Theory come un insieme di nodi, detti sites, rappresentati nell’immagine come sfere gialle, e archi diretti, detti bonds, che nell’immagine sono le linee blu che collegano due nodi.
Avremo quindi un grafo composto da dei nodi sconnessi e da dei cluster, ovvero nodi connessi tra loro tramite archi.
La probabilità che si crei un bond tra due nodi è uguale a p, mentre la probabilità che il bond non esista è uguale a 1-p.

Si osserva che all’aumentare di p aumenta il numero di cluster del grafo, ovvero aumenta il numero di sites connessi.

Immagine7.jpg





Portando la probabilità sopra una certa soglia critica, si avrà un aumento delle connessioni tale da poter osservare il formarsi di giant cluster, e cioè tutta la rete potrà esser attraversata da un capo all’altro.
Il giant cluster dà origine a una Percolation che nell’ambito delle reti è l’equivalente della transizione di fase della fisica, in cui una proprietà si propaga all’intero sistema.

Visto con questo sistema, Gnutella è un protocollo in cui la probabilità che due nodi vicini siano collegati tra loro, è uguale a 1; il che significa che vi è sicuramente un collegamento e possono quindi inviarsi delle query.
Quindi dobbiamo studiare delle soglie che sono dipendenti dalla topologia della rete in esame e cioè che dipendano dal numero di connessioni medie tra i nodi, dalla dimensione della rete e dal grado di clustering che definisce quanto alcune zone della rete siano fortemente connesse tra di loro.
Il grado di clustering di una porzione della rete è calcolato facendo il rapporto tra: il max numero di connessioni possibili (il numero di link con cui ogni nodo è collegato ad ogni altro nodo) e il numero di connessioni effettivamente attive.
Il max numero di connessioni possibili è il risultato del fattoriale, con argomento il numero di nodi della rete meno uno, dato che un nodo non può collegarsi a se stesso.

Esempio: data una rete composta da 4 nodi e avente solo 3 connessioni, otteniamo un grado di clustering di 0,5.
Grado di clustering = ( 4 – 1 )! / 3 = 0,5

Flooding Probabilistico

Il Flooding Probabilistico entra in gioco al fine di determinare quale sia la soglia critica nelle reti P2P, e per calcolarla va ad alterare la probabilità p di connessione tra i nodi.

animated2.gif

Nel protocollo Gnutella abbiamo visto che p=1 e che quindi ogni nodo inoltra le query che riceve a tutti i suoi vicini ed ad ogni passo il TTL viene decrementato fino a che raggiunge il valore di zero.

Tuttavia come si vede nell’immagine, in tal modo si vanno a creare delle connessioni superflue (frecce in rosso) in quanto dei nodi sono raggiungibili seguendo più di un percorso.

Immagine8.jpg

Quindi il Flooding Probabilistico parte dall’idea che due nodi siano tra loro connessi con p=1 e ad ogni passo successivo del processo di lookup va a decrementare la probabilità che si creino nuove connessioni fino a raggiungere p=0. In tal modo il numero di vicini a cui viene inoltrata la query diminuisce di pari passo con la diffusione della query stessa.

Immagine9.jpg

Grazie al flooding probabilistico tagliamo le connessioni superflue senza però andare a pregiudicare la raggiungibilità dei nodi, ottenendo così l’eliminazione dei percorsi alternativi.
In questo modo viene gestito implicitamente anche il Time To Live delle query, che non sarà più un TTL basato solamente su un numero di passaggi oltre i quali interrompere la diffusione della query, ma sarà un TTL dipendente dalla topologia della rete.

Però, dato che nelle reti P2P vale la legge di potenza, avremo degli HUB, e di conseguenza avremo sia pochi nodi molto connessi, sia molti nodi poco connessi, il che comporta un cambiamento di quanto detto finora.
Si mira quindi ad ottenere un settaggio della probabilità del flooding probabilistico dipendente non solo dalla topologia della rete, ma da anche alcune sue proprietà. Infatti, prendendo come esempio Gnutella, vediamo che in base alle proprietà prese in considerazione l’esponenziale y che regola la sua legge di potenza assume valori differenti, e seppure questi valori si trovino tutti in un intorno di 2, causano variazioni anche nel punto critico della rete.
Per alcuni casi è stato calcolato che il punto critico è ottenibile con p = 0,01 cioè con solo 1 centesimo delle query inoltrate ai vicini che raggiunge destinazione.
Inoltre, siccome una rete P2P è anche una struttura in evoluzione nel tempo, ci sono fasi iniziali in cui il flooding probabilistico non è adatto in quanto non è stata ancora costruita la struttura ad HUB, e di conseguenza la legge di potenza non è al momento valida.

Per quanto la Percolation Theory e il Flooding Probabilistico siano la principale alternativa alle DHT, esistono altri approcci molto interessanti, in particolare quello che si basa sulla filosofia del Piccolo Mondo.

Piccolo Mondo

L’idea di fondo è sempre quella di organizzare le risorse in uno spazio continuo, tuttavia, al fine di migliorare le prestazioni, i link tra i nodi verranno creati in base alle risorse in loro possesso.
Questo viene fatto al fine di superare i problemi avuti con Chord a proposito della responsabilità legale delle risorse, che al momento della disconnessione di un nodo dovevano essere passate a un nuovo nodo, con il conseguente passaggio di responsabilità.
Quindi definendo i link sulle risorse dei nodi avremo una rete simile a Chord, ma senza aver bisogno delle Hash Tabels per definire la contiguità tra i nodi.

A livello pratico il protocollo a Piccolo Mondo si compone di tre passi, illustrati nell’immagine sotto:
I - Definisco le risorse/file che ogni nodo possiede facendo un mapping tra nodi e chiavi (le risorse).
Quindi come da immagine avremo il primo nodo che mappa la risorsa 3 e 1, il secondo nodo che mappa la risorsa 4 e così via.
II - Dopodiché tutte le risorse vengono organizzate in ordine crescente in un spazio continuo e collegate tra di loro.
III – Viene costruito il Grafo delle Connessioni in cui ogni nodo viene rappresentato con le proprie risorse, e i link tra i nodi vengono creati in base ai link tra le risorse ottenuti nella fase II.

Immagine10.jpg

Quindi non viene modificata la topologia della rete ma solamente i link tra i suoi nodi e i link vengono scelti in base alla contiguità fra le risorse dei nodi.
Come risultato avremo che nodi con risorse simili saranno vicini tra loro e quindi, attraverso la nozione di similarità tra le risorse, viene generata una rete con topologia piccolo mondo che ci permetterà di eseguire in modo efficiente tutte le query.
Ovviamente questo è possibile solo in presenza di un certo livello di eterogeneità tra i nodi e cioè se i nodi posseggono risorse tra loro simili e se le risorse simili non sono possedute tutte da singoli cluster.
Qui emerge una nozione molto importante e cioè che possiamo definire una topologia di relazioni tra nodi attraverso una similarità di proprietà: fino ad ora, parlando di reti, abbiamo sempre descritto, prima con le reti sociali e poi con Internet, conoscenze fra persone o collegamenti fra nodi, invece con questa nozione diveniamo in grado definire un collegamento in base a una similarità tra i nodi, similarità espressa in base alla loro vicinanza per certe proprietà, per esempio le risorse che sono mantenute.
Col modello a Piccolo Mondo si ha quindi che ogni nodo è responsabile solo delle sue risorse e dovrà mantenere un indice contenente solo i suoi vicini (per ogni vicino manterrà il suo indirizzo IP e le chiavi/risorse di cui quel nodo è proprietario).
Il sistema di lookup funziona attraverso una funzione di similarità che definisce la vicinanza tra le chiavi, e la query viene inoltrata al nodo vicino che possiede la chiave più simile a quella cercata.

Sostanzialmente viene sovrascritta alla struttura della rete, la struttura di vicinanza delle risorse della rete. Lo spazio così suddiviso ci permette di ottenere una gerarchia che in fase di lookup viene utilizzata per inoltrare le query a un sottogruppo di nodi escludendo tutti gli altri sottogruppi. Un approccio simile viene utilizzato anche nell’indicizzazione dei database.
Tale gerarchia, oltre a permettere l’ottimizzazione dei tempi e delle risorse, è anche facilmente trasformabile in una gerarchia di relazioni e quindi in una rete che connette dei nodi tra di loro.

Immagine11.jpg
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License