Blockchain — una tecnologia innovativa che promette di migliorare molte aree della vita umana. Essa trasferisce processi e prodotti reali nel mondo digitale, garantendo velocità e affidabilità nelle operazioni finanziarie, riducendo i loro costi, e permettendo la creazione di moderne applicazioni DAPP utilizzando contratti intelligenti in reti decentralizzate.
Considerati i numerosi vantaggi e le diverse aree di applicazione della blockchain, potrebbe sembrare strano che questa promettente tecnologia non sia ancora penetrata in tutti i settori. Il problema è che le blockchain decentralizzate moderne mancano di scalabilità. Ethereum gestisce circa 20 transazioni al secondo, una cifra insufficiente per soddisfare le esigenze delle aziende moderne e dinamiche. Nel frattempo, le aziende che utilizzano la tecnologia blockchain esitano ad abbandonare Ethereum a causa del suo elevato livello di protezione contro attacchi e malfunzionamenti di rete.
Per garantire decentralizzazione, sicurezza e scalabilità nella blockchain, affrontando così il Trilemma della Scalabilità, il team di sviluppatori ha creato Plasma Cash — una sidechain composta da un contratto intelligente e una rete privata basata su Node.js, che trasmette periodicamente il proprio stato alla blockchain principale (Ethereum).

Processi chiave in Plasma Cash
1. L'utente richiama la funzione del contratto intelligente `deposit`, passando l'importo in ETH che desidera depositare nel token Plasma Cash. La funzione del contratto intelligente crea il token e genera un evento a riguardo.
2. I nodi Plasma Cash, iscritti agli eventi del contratto intelligente, ricevono l'evento di creazione del deposito e aggiungono la transazione di creazione del token al pool.
3. Periodicamente, nodi speciali di Plasma Cash prendono tutte le transazioni dal pool (fino a 1 milione) e formano un blocco da esse, calcolano l'albero di Merkle e, di conseguenza, l'hash. Questo blocco viene inviato ad altri nodi per la verifica. I nodi controllano se l'hash di Merkle è valido e se le transazioni lo sono (per esempio, se il mittente del token è il suo proprietario). Dopo la verifica del blocco, il nodo richiama la funzione `submitBlock` del contratto intelligente, che salva nella blockchain principale il numero e l'hash di Merkle del blocco. Il contratto intelligente genera un evento sul successo dell'aggiunta del blocco. Le transazioni vengono rimosse dal pool.
4. I nodi che ricevono l'evento di submit del blocco iniziano ad applicare le transazioni che sono state aggiunte al blocco.
5. Ad un certo punto, il proprietario (o non proprietario) del token desidera prelevarlo da Plasma Cash. A tal fine, chiama la funzione `startExit`, passando le informazioni sulle ultime 2 transazioni riguardanti il token, che confermano che egli è il legittimo proprietario. Il contratto intelligente, utilizzando l'hash Merkle, verifica la presenza delle transazioni nei blocchi e invia il token per il prelievo, che avverrà dopo due settimane.
6. Se l'operazione di prelievo del token avviene in violazione delle regole (il token è stato speso dopo l'inizio della procedura di prelievo o il token era già di proprietà di qualcun altro prima del prelievo), il proprietario del token può contestare il prelievo entro due settimane.

La privacy viene raggiunta in due modi.
1. La catena principale non ha conoscenza delle transazioni che vengono generate e inviate all'interno della catena secondaria. Rimane pubblica l'informazione su chi ha depositato e prelevato ETH da/a Plasma Cash.
2. La catena secondaria consente di organizzare transazioni anonime utilizzando zk-SNARKs.
Stack tecnologico
- NodeJS
- Redis
- Etherium
- Soild
Test
Sviluppando Plasma Cash, abbiamo testato la velocità del sistema e ottenuto i seguenti risultati:
- fino a 35.000 transazioni al secondo vengono aggiunte al pool;
- fino a 1.000.000 transazioni possono essere memorizzate in un blocco.
I test sono stati condotti su 3 seguenti server:
1. Intel Core i7-6700 Quad-Core Skylake con NVMe SSD — 512 GB, 64 GB di RAM DDR4
Sono stati messi in funzione 3 nodi di validazione Plasma Cash.
2. AMD Ryzen 7 1700X Octa-Core «Summit Ridge» (Zen), SATA SSD — 500 GB, 64 GB di RAM DDR4
È stato avviato un nodo di testnet ETH Ropsten.
Sono stati messi in funzione 3 nodi di validazione Plasma Cash.
3. Intel Core i9-9900K Octa-Core con NVMe SSD — 1 TB, 64 GB di RAM DDR4
È stato avviato 1 nodo di submission Plasma Cash.
Sono stati messi in funzione 3 nodi di validazione Plasma Cash.
È stato avviato il test per l'aggiunta di transazioni nella rete Plasma Cash.
In totale: 10 nodi Plasma Cash in rete privata.
Test 1
C'è un limite di 1 milione di transazioni per blocco. Pertanto, 1 milione di transazioni finiscono in 2 blocchi (poiché il sistema riesce a prendere parte delle transazioni e a sottometterle mentre vengono inviate).

Stato iniziale: ultimo blocco #7; nella base sono salvate 1 milione di transazioni e token.
00:00 — avvio dello script di generazione delle transazioni
01:37 — creato 1 milione di transazioni e iniziata l'invio al nodo
01:46 — il nodo di submission ha preso dal pool 240k transazioni e sta formando il blocco #8. Vediamo anche che nel pool vengono aggiunte 320k transazioni in 10 secondi.
01:58 — il blocco #8 è stato firmato e inviato per la validazione
02:03 — il blocco #8 è stato convalidato e la funzione `submitBlock` del contratto intelligente è stata chiamata con l'hash Merkle e il numero di blocco
02:10 — il demo script ha terminato il lavoro, inviando 1 milione di transazioni in 32 secondi
02:33 — i nodi hanno iniziato a ricevere informazioni che il blocco #8 è stato aggiunto alla catena principale e hanno cominciato a eseguire 240k transazioni
02:40 — sono state rimosse dalla pool 240k transazioni, già nel blocco #8
02:56 — il nodo di invio ha preso dalla pool le restanti 760k transazioni e ha iniziato a calcolare l'hash Merkle e firmare il blocco #9
03:20 — tutti i nodi contengono 1 milione e 240k transazioni e token
03:35 — il blocco #9 è stato firmato e inviato per la validazione ad altri nodi
03:41 — si è verificato un errore di rete
04:40 — l'attesa per la validazione del blocco #9 è terminata per timeout
04:54 — il nodo di invio ha preso dalla pool le restanti 760k transazioni e ha iniziato a calcolare l'hash Merkle e firmare il blocco #9
05:32 — il blocco #9 è stato firmato e inviato per la validazione ad altri nodi
05:53 — il blocco #9 è stato convalidato e inviato alla catena principale
06:17 — i nodi hanno iniziato a ricevere informazioni che il blocco #9 è stato aggiunto alla catena principale e hanno cominciato a eseguire 760k transazioni
06:47 — il pool si è liberato dalle transazioni nel blocco #9
09:06 — tutti i nodi contengono 2 milioni di transazioni e token
Test 2
C'è un limite di 350k per blocco. Abbiamo quindi 3 blocchi.

Stato iniziale: ultimo blocco #9; nel database sono salvati 2 milioni di transazioni e token
00:00 — lo script di generazione delle transazioni è già stato avviato
00:44 — creati 1 milione di transazioni e inviate al nodo
00:56 — il nodo ha preso 320k transazioni dal pool e sta formando il blocco #10. Vediamo anche che nel pool vengono aggiunti 320k transazioni in 10 secondi
01:12 — il blocco #10 è stato firmato e inviato ad altri nodi per la validazione
01:18 — ha terminato di funzionare lo script demo, che ha inviato 1 milione di transazioni in 34 secondi
01:20 — il blocco #10 è stato validato ed è stato inviato alla catena principale
01:51 — tutti i nodi hanno ricevuto dalla catena principale l'informazione che il blocco #10 è stato aggiunto e iniziano ad applicare 320k transazioni
02:01 — il pool si è liberato di 320k transazioni, che sono state aggiunte al blocco #10
02:15 — il nodo ha preso 350k transazioni dal pool e sta formando il blocco #11
02:34 — il blocco #11 è stato firmato e inviato ad altri nodi per la validazione
02:51 — il blocco #11 è stato validato ed è stato inviato alla catena principale
02:55 — l'ultima nodo ha completato le transazioni dal blocco #10
10:59 — ci è voluto molto tempo per completare la transazione con la submission del blocco #9 nella catena principale, ma è stata completata e tutte le nodi ne sono state informate e hanno iniziato a eseguire 350k transazioni
11:05 — il pool si è svuotato di 320k transazioni, che sono state aggiunte al blocco #11
12:10 — tutte le nodi contengono 1 milione 670k transazioni e token
12:17 — il nodo di submission ha prelevato dal pool 330k transazioni e sta formando il blocco #12
12:32 — il blocco #12 è stato firmato ed è inviato ad altre nodi per la validazione
12:39 — il blocco #12 è stato validato ed è stato inviato nella catena principale
13:44 — tutte le nodi hanno ricevuto dalla catena principale l'informazione che il blocco #12 è stato aggiunto e iniziano ad applicare 330k transazioni
14:50 — tutte le nodi contengono 2 milioni di transazioni e token
Test 3
Nel primo e nel secondo server, un nodo di validazione è stato sostituito con un nodo di submission.

Stato iniziale: ultimo blocco #84; nel database sono salvate 0 transazioni e token
00:00 — Sono stati avviati 3 script che generano e inviano 1 milione di transazioni ciascuno
01:38 — sono state create 1 milione di transazioni e l'invio al nodo di submission #3 è iniziato
01:50 — il nodo di submission #3 ha preso 330k transazioni dal pool e sta formando il blocco #85 (f21). Vediamo anche che nel pool si aggiungono 350k transazioni in 10 secondi.
01:53 — sono state create 1 milione di transazioni e è iniziata la spedizione al nodo di submission #1.
01:50 — il nodo di submission #3 ha preso 330k transazioni dal pool e sta formando il blocco #85 (f21). Vediamo anche che nel pool si aggiungono 350k transazioni in 10 secondi.
02:01 — il nodo di submission #1 ha preso 250k transazioni dal pool e sta formando il blocco #85 (65e).
02:06 — il blocco #85 (f21) è stato firmato e inviato ad altri nodi per la validazione.
02:08 — è terminato il lavoro dello script demo del server #3, che ha inviato 1 milione di transazioni in 30 secondi.
02:14 — il blocco #85 (f21) è stato convalidato ed è stato inviato nella catena principale.
02:19 — il blocco #85 (65e) è stato firmato e inviato ad altri nodi per la validazione.
02:22 — sono state create 1 milione di transazioni e è iniziata la spedizione al nodo di submission #2.
02:27 — il blocco #85 (65e) è stato convalidato ed è stato inviato nella catena principale.
02:29 — il nodo di submission #2 ha preso 111855 transazioni dal pool e sta formando il blocco #85 (256).
02:36 — il blocco #85 (256) è stato firmato e inviato ad altri nodi per la validazione.
02:36 — è terminato il lavoro dello script demo del server #1, che ha inviato 1 milione di transazioni in 42.5 secondi.
02:38 — il blocco #85 (256) è stato convalidato ed è stato inviato nella catena principale.
03:08 — è terminato il lavoro dello script demo del server #2, che ha inviato 1 milione di transazioni in 47 secondi.
03:38 — tutti i nodi hanno ricevuto dalla catena principale informazioni riguardo ai blocchi #85 (f21), #86(65e), #87(256) aggiunti e iniziano ad applicare 330k, 250k, 111855 transazioni
03:49 — il pool si è svuotato di 330k, 250k, 111855 transazioni, che sono state aggiunte ai blocchi #85 (f21), #86(65e), #87(256)
03:59 — il nodo submit #1 ha prelevato dal pool 888145 transazioni e sta formando il blocco #88 (214), il nodo submit #2 ha prelevato dal pool 750k transazioni e sta formando il blocco #88 (50a), il nodo submit #3 ha prelevato dal pool 670k transazioni e sta formando il blocco #88 (d3b)
04:44 — il blocco #88 (d3b) è stato firmato e inviato agli altri nodi per la validazione
04:58 — il blocco #88 (214) è stato firmato e inviato agli altri nodi per la validazione
05:11 — il blocco #88 (50a) è stato firmato e inviato agli altri nodi per la validazione
05:11 — il blocco #85 (d3b) è stato convalidato e inviato alla catena principale
05:36 — il blocco #85 (214) è stato convalidato e inviato alla catena principale
05:43 — tutti i nodi hanno ricevuto dalla catena principale informazioni riguardo ai blocchi #88 (d3b), #89(214) aggiunti e iniziano ad applicare 670k, 750k transazioni
06:50 — a causa di un'interruzione della connessione, il blocco #85 (50a) non è stato convalidato
06:55 — il nodo submit #2 ha prelevato dal pool 888145 transazioni e sta formando il blocco #90 (50a)
08:14 — il blocco #90 (50a) è stato firmato e inviato agli altri nodi per la validazione
09:04 — blocco #90 (50a) validato e inviato nella catena principale
11:23 — tutti i nodi hanno ricevuto dalla catena principale l'informazione che il blocco #90 (50a) è stato aggiunto e iniziano ad applicare 888145 transazioni. Nel frattempo, il server #3 ha già applicato le transazioni dai blocchi #88 (d3b), #89(214)
12:11 — tutti i pool sono vuoti
13:41 — tutti i nodi del server #3 contengono 3 milioni di transazioni e token
14:35 — tutti i nodi del server #1 contengono 3 milioni di transazioni e token
19:24 — tutti i nodi del server #2 contengono 3 milioni di transazioni e token
Ostacoli
Durante lo sviluppo di Plasma Cash, abbiamo affrontato i seguenti problemi, che abbiamo risolto gradualmente e continuiamo a risolvere:
1. Conflitto tra le diverse funzioni del sistema. Ad esempio, la funzione di aggiunta delle transazioni nel pool bloccava il funzionamento della sottomissione e della validazione dei blocchi e viceversa, causando un calo della velocità.
2. Non era subito chiaro come inviare un enorme numero di transazioni e contemporaneamente minimizzare i costi di trasferimento dei dati.
3. Non era chiaro come e dove archiviare i dati per raggiungere alti risultati.
4. Non era chiaro come organizzare la rete tra i nodi, poiché la dimensione di un blocco con 1 milione di transazioni occupa circa 100 MB.
5. Il funzionamento in modalità monothread interrompe la connessione tra i nodi quando si effettuano calcoli lunghi (ad esempio, la costruzione dell'albero di Merkle e il calcolo del suo hash).
Come abbiamo affrontato tutto ciò?
La prima versione del nodo Plasma Cash era un po' un ibrido, capace di fare tutto contemporaneamente: ricevere transazioni, inviare e convalidare blocchi, fornire un'API per l'accesso ai dati. Poiché NodeJS è originariamente monothread, la pesante funzione di calcolo dell'albero di Merkle bloccava la funzione di aggiunta delle transazioni. Abbiamo visto due opzioni per risolvere questo problema:
1. Avviare più processi NodeJS, ognuno dei quali esegue funzioni specifiche.
2. Utilizzare worker_threads ed esternalizzare l'esecuzione di parte del codice nei thread.
Alla fine abbiamo utilizzato entrambe le soluzioni contemporaneamente: abbiamo logicamente suddiviso un nodo in 3 parti, che possono lavorare separatamente ma allo stesso tempo in modo sincronizzato.
1. Il nodo di invio, che accetta transazioni nel pool e si occupa della creazione dei blocchi.
2. Il nodo di convalida, che verifica la validità dei nodi.
3. Il nodo API — fornisce un'API per l'accesso ai dati.
Ogni nodo può essere collegato tramite socket unix utilizzando il cli.
Operazioni pesanti, come il calcolo dell'albero di Merkle, sono state spostate in un thread separato.
In questo modo, abbiamo raggiunto un funzionamento regolare di tutte le funzionalità di Plasma Cash simultaneamente e senza interruzioni.
Una volta che il sistema è entrato in funzione, abbiamo iniziato a testare la velocità e, sfortunatamente, abbiamo ottenuto risultati insoddisfacenti: 5.000 transazioni al secondo e fino a 50.000 transazioni per blocco. Abbiamo dovuto scoprire cosa fosse implementato in modo errato.
Inizialmente, abbiamo iniziato a testare il meccanismo di comunicazione con Plasma Cash per conoscere la capacità di picco del sistema. In precedenza, abbiamo scritto che il nodo Plasma Cash fornisce un'interfaccia socket unix. Inizialmente era testuale. Gli oggetti json venivano inviati utilizzando `JSON.parse()` e `JSON.stringify()`.
```json
{
"action": "sendTransaction",
"payload":{
"prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0",
"prevBlock": 41,
"tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445",
"newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4",
"type": "pay",
"data": "",
"signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"
}
}
```
Abbiamo misurato la velocità di trasferimento di tali oggetti e abbiamo ottenuto circa 130k al secondo. Abbiamo provato a sostituire le funzioni standard per lavorare con JSON, ma le prestazioni non sono migliorate. Deve essere che il motore V8 sia ben ottimizzato per queste operazioni.
Il lavoro con transazioni, token e blocchi è stato effettuato tramite classi. Nel momento in cui abbiamo creato tali classi, le prestazioni sono scese di 2 volte, il che dimostra che la programmazione orientata agli oggetti non fa per noi. Abbiamo dovuto riscrivere tutto con un approccio puramente funzionale.
Scrittura nel database
Inizialmente, Redis è stato scelto come una delle soluzioni più performanti per l'archiviazione dei dati, in quanto soddisfa i nostri requisiti: archiviazione key-value, gestione delle hash table e insiemi. Abbiamo lanciato redis-benchmark e ottenuto circa 80k operazioni al secondo in modalità 1 pipelining.
Per ottenere alte prestazioni, abbiamo configurato Redis in modo più preciso:
- Abbiamo stabilito una connessione tramite socket unix.
- Abbiamo disabilitato il salvataggio dello stato su disco (per la sicurezza, è possibile configurare una replica e già in un altro Redis effettuare il salvataggio su disco).
In Redis, a pool is a hash table because we need the ability to retrieve all transactions with a single request and delete transactions individually. We tried using a regular list, but it was slower when unloading the entire list.
When using the standard NodeJS Redis library, we achieved a performance of 18k transactions per second. The speed dropped by a factor of 9.
Since the benchmark showed us capabilities that were clearly 5 times greater, we started optimizing. We switched the library to ioredis and achieved a performance of 25k per second. We were adding transactions individually using the `hset` command. This way, we generated a lot of requests to Redis. The idea arose to batch transactions together and send them as a single `hmset` command. The result — 32k per second.
For several reasons that we will describe below, we work with the data using `Buffer` and, as it turned out, if we convert it to text (`buffer.toString('hex')`) before writing, we can gain additional performance. Thus, we managed to improve the speed to 35k per second. At this point, we have decided to pause further optimization.
Abbiamo dovuto passare a un protocollo binario perché:
1. Il sistema calcola frequentemente hash, firme, ecc., e per questo ha bisogno dei dati in `Buffer.
2. Quando invii dati tra servizi, i dati binari occupano meno spazio rispetto al testo. Ad esempio, inviando un blocco con 1 milione di transazioni, i dati in formato testo possono superare i 300 megabyte.
3. La continua conversione dei dati influisce sulle prestazioni.
Pertanto, abbiamo adottato un protocollo di memorizzazione e trasmissione dati binario proprietario, sviluppato sulla base dell'ottima libreria `binary-data`.
Di conseguenza, abbiamo ottenuto le seguenti strutture dati:
— Transazione
```json
{
prevHash: BD.types.buffer(20),
prevBlock: BD.types.uint24le,
tokenId: BD.types.string(null),
type: BD.types.uint8,
newOwner: BD.types.buffer(20),
dataLength: BD.types.uint24le,
data: BD.types.buffer(({current}) => current.dataLength),
signature: BD.types.buffer(65),
hash: BD.types.buffer(32),
blockNumber: BD.types.uint24le,
timestamp: BD.types.uint48le,
}
```
— Token
```json
{
id: BD.types.string(null),
owner: BD.types.buffer(20),
block: BD.types.uint24le,
amount: BD.types.string(null),
}
```
— Blocco
```json
{
number: BD.types.uint24le,
merkleRootHash: BD.types.buffer(32),
signature: BD.types.buffer(65),
countTx: BD.types.uint24le,
transactions: BD.types.array(Transaction.Protocol, ({current}) => current.countTx),
timestamp: BD.types.uint48le,
}
```
Con i comandi `BD.encode(block, Protocol).slice();` e `BD.decode(buffer, Protocol)`, trasformiamo i dati in `Buffer` per la memorizzazione in Redis o per l'invio a un'altra nodo e l'estrazione dei dati.
Abbiamo anche 2 protocolli binari per la trasmissione dei dati tra i servizi:
— Protocollo per l'interazione con Plasma Node tramite unix socket
```json
{
type: BD.types.uint8,
messageId: BD.types.uint24le,
error: BD.types.uint8,
length: BD.types.uint24le,
payload: BD.types.buffer(({node}) => node.length)
}
```
dove:
- `type` — l'azione da eseguire, ad esempio, 1 — sendTransaction, 2 — getTransaction;
- `payload` — i dati da inviare alla funzione corrispondente;
- `messageId` — ID del messaggio, per identificare la risposta.
— Protocollo di interazione tra nodi
```json
{
code: BD.types.uint8,
versionProtocol: BD.types.uint24le,
seq: BD.types.uint8,
countChunk: BD.types.uint24le,
chunkNumber: BD.types.uint24le,
length: BD.types.uint24le,
payload: BD.types.buffer(({node}) => node.length)
}
```
dove:
- `code` — codice del messaggio, ad esempio 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
- `versionProtocol` — versione del protocollo, poiché nella rete possono esserci nodi con versioni diverse e possono funzionare in modo diverso;
- `seq` — identificatore del messaggio;
- `countChunk` e `chunkNumber` necessari per suddividere messaggi grandi;
- `length` e `payload` lunghezza e i dati stessi.
Poiché abbiamo tipizzato i dati in anticipo, il sistema finale funziona molto più velocemente della libreria `rlp` di Ethereum. Sfortunatamente, non siamo ancora riusciti a rinunciare ad essa, poiché è necessario perfezionare il contratto smart, cosa che intendiamo fare in futuro.
Se siamo riusciti a raggiungere la velocità 35 000 di transazioni al secondo, dobbiamo anche gestirle in tempi ottimali. Poiché il tempo medio di formazione di un blocco è di 30 secondi, dobbiamo includere nel blocco 1 000 000 transazioni, il che significa trasferire oltre 100 MB di dati.
Inizialmente abbiamo utilizzato la libreria `ethereumjs-devp2p` per la comunicazione tra nodi, ma non riusciva a gestire una quantità così elevata di dati. Di conseguenza, abbiamo utilizzato la libreria `ws` e configurato il trasferimento di dati binari tramite websocket. Certamente abbiamo anche incontrato problemi nel trasferimento di grandi pacchetti di dati, ma li abbiamo suddivisi in chunk e ora questi problemi non ci sono più.
Anche la formazione dell'albero di Merkle e il calcolo dell'hash 1 000 000 delle transazioni richiede circa 10 secondi di calcolo continuo. Durante questo tempo, la connessione con tutti i nodi può andare persa. È stata presa la decisione di trasferire questo calcolo in un thread separato.
Conclusioni:
In effetti, le nostre conclusioni non sono nuove, ma per qualche motivo molti specialisti le dimenticano durante lo sviluppo.
- Utilizzare la Programmazione Funzionale invece della Programmazione Orientata agli Oggetti aumenta le prestazioni.
- Il monolite è peggiore rispetto all'architettura a servizi per un sistema performante su NodeJS.
- L'uso di `worker_threads` per calcoli intensivi migliora la reattività del sistema, specialmente durante le operazioni di I/O.
- I socket unix sono più stabili e veloci delle richieste http.
- Se è necessario trasferire rapidamente grandi quantità di dati attraverso la rete, è meglio utilizzare i websocket e inviare dati binari suddivisi in chunk che possono essere ri-inviati se non arrivano, e poi ricomposti in un unico messaggio.
Ti invitiamo a visitare GitHub del progetto:
L'articolo è stato scritto in collaborazione con Alexander Nashivan, senior developer di .
Fonte: habr.com
