Javni test: Ethereum rješenje za privatnost i skalabilnost

Blockchain je inovativna tehnologija koja obećava poboljšanje mnogih područja ljudskog života. On prenosi stvarne procese i proizvode u digitalni prostor, osigurava brzinu i pouzdanost finansijskih transakcija, smanjuje njihovu cijenu, a također vam omogućava da kreirate moderne DAPP aplikacije koristeći pametne ugovore u decentraliziranim mrežama.

S obzirom na mnoge prednosti i različite primjene blockchaina, može izgledati iznenađujuće da ova obećavajuća tehnologija još nije ušla u svaku industriju. Problem je u tome što modernim decentraliziranim blockchainima nedostaje skalabilnost. Ethereum obrađuje oko 20 transakcija u sekundi, što nije dovoljno da zadovolji potrebe današnjeg dinamičnog poslovanja. Istovremeno, kompanije koje koriste blockchain tehnologiju oklevaju da napuste Ethereum zbog njegovog visokog stepena zaštite od hakovanja i kvarova na mreži.

Kako bi se osigurala decentralizacija, sigurnost i skalabilnost u blockchainu, rješavajući tako trilemu skalabilnosti, razvojni tim Opporty kreirao je Plasma Cash, lanac podružnica koji se sastoji od pametnog ugovora i privatne mreže zasnovane na Node.js, koja periodično prenosi svoje stanje u korijenski lanac (Ethereum).

Javni test: Ethereum rješenje za privatnost i skalabilnost

Ključni procesi u Plasma Cash

1. Korisnik naziva funkciju pametnog ugovora `deposit`, prenoseći u nju iznos ETH koji želi uplatiti u token Plasma Cash. Funkcija pametnog ugovora kreira token i generiše događaj o njemu.

2. Plasma Cash čvorovi pretplaćeni na događaje pametnog ugovora primaju događaj o kreiranju depozita i dodaju transakciju o kreiranju tokena u skup.

3. Povremeno, posebni Plasma Cash čvorovi uzimaju sve transakcije iz pula (do 1 milion) i od njih formiraju blok, izračunavaju Merkle stablo i, shodno tome, hash. Ovaj blok se šalje drugim čvorovima na verifikaciju. Čvorovi provjeravaju da li je Merkle heš ispravan i da li su transakcije važeće (na primjer, da li je pošiljatelj tokena njegov vlasnik). Nakon verifikacije bloka, čvor poziva funkciju `submitBlock` pametnog ugovora, koja sprema broj bloka i Merkle heš u rubni lanac. Pametni ugovor generiše događaj koji ukazuje na uspješno dodavanje bloka. Transakcije se uklanjaju iz skupa.

4. Čvorovi koji prime događaj podnošenja bloka počinju primjenjivati ​​transakcije koje su dodane u blok.

5. U nekom trenutku, vlasnik (ili ne-vlasnik) tokena želi da ga povuče iz Plasma Cash-a. Da bi to učinio, on poziva funkciju `startExit`, prenoseći joj informacije o posljednje 2 transakcije na tokenu, koje potvrđuju da je on vlasnik tokena. Pametni ugovor, koristeći Merkle heš, provjerava prisutnost transakcija u blokovima i šalje token na povlačenje, što će se dogoditi za dvije sedmice.

6. Ako je do operacije povlačenja tokena došlo s kršenjima (token je potrošen nakon što je postupak povlačenja započeo ili je token već bio nečiji prije povlačenja), vlasnik tokena može pobiti povlačenje u roku od dvije sedmice.

Javni test: Ethereum rješenje za privatnost i skalabilnost

Privatnost se postiže na dva načina

1. Korijenski lanac ne zna ništa o transakcijama koje se generiraju i prosljeđuju unutar podređenog lanca. Informacije o tome ko je deponovao i povukao ETH iz Plasma Cash-a ostaju javne.

2. Podređeni lanac dozvoljava anonimne transakcije koristeći zk-SNARK-ove.

Tehnološki stog

  • NodeJS
  • Redis
  • Eterijum
  • tlo

Testiranje

Prilikom razvoja Plasma Cash-a testirali smo brzinu sistema i dobili sljedeće rezultate:

  • do 35 transakcija u sekundi se dodaje u skup;
  • do 1 transakcija može biti pohranjeno u bloku.

Testovi su obavljeni na sljedeća 3 servera:

1. Intel Core i7-6700 Quad-Core Skylake uklj. NVMe SSD – 512 GB, 64 GB DDR4 RAM-a
Podignuta su 3 validirajuća Plasma Cash čvora.

2. AMD Ryzen 7 1700X Octa-Core “Summit Ridge” (Zen), SATA SSD – 500 GB, 64 GB DDR4 RAM-a
Ropsten testnet ETH čvor je podignut.
Podignuta su 3 validirajuća Plasma Cash čvora.

3. Intel Core i9-9900K Octa-Core uklj. NVMe SSD – 1 TB, 64 GB DDR4 RAM-a
1 Plasma Cash čvor za podnošenje je podignut.
Podignuta su 3 validirajuća Plasma Cash čvora.
Pokrenut je test za dodavanje transakcija u mrežu Plasma Cash.

Ukupno: 10 Plasma Cash čvorova u privatnoj mreži.

Test 1

Postoji ograničenje od 1 milion transakcija po bloku. Dakle, 1 milion transakcija pada u 2 bloka (pošto sistem uspeva da uzme deo transakcija i dostavi dok se šalju).


Početno stanje: zadnji blok #7; 1 milion transakcija i tokena je pohranjeno u bazi podataka.

00:00 — početak skripte za generiranje transakcije
01:37 - 1 milion transakcija je kreirano i počelo je slanje na čvor
01:46 — Submit čvor je uzeo 240k transakcija iz skupa i formira blok #8. Također vidimo da se 320 transakcija dodaje u bazen za 10 sekundi
01:58 — blok #8 je potpisan i poslan na validaciju
02:03 — blok #8 je potvrđen i funkcija `submitBlock` pametnog ugovora se poziva s Merkle hash i brojem bloka
02:10 — završila sa radom demo skripta koja je poslala milion transakcija za 1 sekunde
02:33 - čvorovi su počeli primati informaciju da je blok #8 dodat u korijenski lanac i počeli su obavljati 240k transakcija
02:40 - 240k transakcija je uklonjeno iz bazena, koje su već u bloku #8
02:56 — submit čvor uzeo je preostalih 760 transakcija iz skupa i počeo izračunavati Merkle heš i potpisni blok #9
03:20 - svi čvorovi sadrže 1 milion 240k transakcija i tokena
03:35 — blok #9 je potpisan i poslan na validaciju drugim čvorovima
03:41 - došlo je do greške u mreži
04:40 — čekanje na validaciju bloka #9 je isteklo
04:54 — submit čvor uzeo je preostalih 760 transakcija iz skupa i počeo izračunavati Merkle heš i potpisni blok #9
05:32 — blok #9 je potpisan i poslan na validaciju drugim čvorovima
05:53 — blok #9 je potvrđen i poslan u korijenski lanac
06:17 - čvorovi su počeli primati informaciju da je blok #9 dodat u korijenski lanac i počeo da izvodi 760k transakcija
06:47 — skup je očišćen od transakcija koje se nalaze u bloku #9
09:06 - svi čvorovi sadrže 2 miliona transakcija i tokena

Test 2

Postoji ograničenje od 350k po bloku. Kao rezultat, imamo 3 bloka.


Početno stanje: zadnji blok #9; 2 miliona transakcija i tokena je pohranjeno u bazi podataka

00:00 — skripta za generiranje transakcija je već pokrenuta
00:44 - 1 milion transakcija je kreirano i počelo je slanje na čvor
00:56 — Submit čvor je uzeo 320k transakcija iz skupa i formira blok #10. Također vidimo da se 320 transakcija dodaje u bazen za 10 sekundi
01:12 — blok #10 je potpisan i poslan drugim čvorovima na validaciju
01:18 — završila sa radom demo skripta koja je poslala milion transakcija za 1 sekunde
01:20 — blok #10 je potvrđen i poslan u korijenski lanac
01:51 - svi čvorovi su primili informaciju iz korijenskog lanca da je blok #10 dodat i počinju primjenjivati ​​320k transakcija
02:01 - bazen je očišćen za 320k transakcija koje su dodate u blok #10
02:15 — Submit čvor je uzeo 350 transakcija iz skupa i formira blok #11
02:34 — blok #11 je potpisan i poslan drugim čvorovima na validaciju
02:51 — blok #11 je potvrđen i poslan u korijenski lanac
02:55 — posljednji čvor je završio transakcije iz bloka #10
10:59 — transakcija sa predajom bloka #9 trajala je jako dugo u root lancu, ali je završena i svi čvorovi su primili informacije o tome i počeli da obavljaju 350k transakcija
11:05 - bazen je očišćen za 320k transakcija koje su dodate u blok #11
12:10 - svi čvorovi sadrže 1 milion 670 hiljada transakcija i tokena
12:17 — Submit čvor je uzeo 330 transakcija iz skupa i formira blok #12
12:32 — blok #12 je potpisan i poslan drugim čvorovima na validaciju
12:39 — blok #12 je potvrđen i poslan u korijenski lanac
13:44 - svi čvorovi su primili informaciju iz korijenskog lanca da je blok #12 dodat i počinju primjenjivati ​​330k transakcija
14:50 - svi čvorovi sadrže 2 miliona transakcija i tokena

Test 3

Na prvom i drugom serveru, jedan čvor za provjeru valjanosti zamijenjen je čvorom za slanje.


Početno stanje: zadnji blok #84; 0 transakcija i tokena sačuvanih u bazi podataka

00:00 — Pokrenute su 3 skripte koje generišu i šalju po 1 milion transakcija
01:38 — 1 milion transakcija je kreirano i počelo je slanje na submit čvor #3
01:50 — Submit čvor #3 uzeo je 330k transakcija iz skupa i formira blok #85 (f21). Također vidimo da se 350 transakcija dodaje u bazen za 10 sekundi
01:53 — 1 milion transakcija je kreirano i počelo je slanje na submit čvor #1
01:50 — Submit čvor #3 uzeo je 330k transakcija iz skupa i formira blok #85 (f21). Također vidimo da se 350 transakcija dodaje u bazen za 10 sekundi
02:01 — Submit čvor #1 uzeo je 250 transakcija iz skupa i formira blok #85 (65e)
02:06 — blok #85 (f21) je potpisan i poslan drugim čvorovima na validaciju
02:08 — demo skript servera #3, koji je poslao milion transakcija za 1 sekundi, završio sa radom
02:14 — blok #85 (f21) je potvrđen i poslan u korijenski lanac
02:19 — blok #85 (65e) je potpisan i poslan drugim čvorovima na validaciju
02:22 — 1 milion transakcija je kreirano i počelo je slanje na submit čvor #2
02:27 — blok #85 (65e) potvrđen i poslan u korijenski lanac
02:29 — Submit čvor #2 uzeo je 111855 transakcija iz skupa i formira blok #85 (256).
02:36 — blok #85 (256) je potpisan i poslan drugim čvorovima na validaciju
02:36 — demo skript servera #1, koji je poslao milion transakcija za 1 sekundi, završio sa radom
02:38 — blok #85 (256) je potvrđen i poslan u korijenski lanac
03:08 — skripta servera #2 je završila sa radom, koja je poslala milion transakcija za 1 sekundi
03:38 - svi čvorovi su primili informaciju iz korijenskog lanca da su dodani blokovi #85 (f21), #86(65e), #87(256) i počeli primjenjivati ​​330k, 250k, 111855 transakcija
03:49 - bazen je očišćen na 330k, 250k, 111855 transakcija koje su dodate blokovima #85 (f21), #86(65e), #87(256)
03:59 — submit čvor #1 uzeo je 888145 transakcija iz skupa i blok obrazaca #88 (214), submit čvor #2 je uzeo 750 transakcija iz skupa i blok obrazaca #88 (50a), submit čvor #3 uzeo je 670 transakcija od bazen i forme blok #88 (d3b)
04:44 — blok #88 (d3b) je potpisan i poslan drugim čvorovima na validaciju
04:58 — blok #88 (214) je potpisan i poslan drugim čvorovima na validaciju
05:11 — blok #88 (50a) je potpisan i poslan drugim čvorovima na validaciju
05:11 — blok #85 (d3b) je potvrđen i poslan u korijenski lanac
05:36 — blok #85 (214) je potvrđen i poslan u korijenski lanac
05:43 - svi čvorovi su primili informacije iz korijenskog lanca koji blokovi #88 (d3b), #89(214) su dodani i počinju primjenjivati ​​670k, 750k transakcija
06:50 — zbog kvara u komunikaciji, blok #85 (50a) nije potvrđen
06:55 — Submit čvor #2 uzeo je 888145 transakcija iz skupa i blok obrazaca #90 (50a)
08:14 — blok #90 (50a) je potpisan i poslan drugim čvorovima na validaciju
09:04 — blok #90 (50a) je potvrđen i poslan u korijenski lanac
11:23 - svi čvorovi su primili informaciju iz korijenskog lanca da je dodat blok #90 (50a) i počinju primjenjivati ​​888145 transakcija. U isto vrijeme, server #3 je već primijenio transakcije iz blokova #88 (d3b), #89(214)
12:11 - svi bazeni su prazni
13:41 — svi čvorovi servera #3 sadrže 3 miliona transakcija i tokena
14:35 — svi čvorovi servera #1 sadrže 3 miliona transakcija i tokena
19:24 — svi čvorovi servera #2 sadrže 3 miliona transakcija i tokena

Prepreke

Tokom razvoja Plasma Cash-a naišli smo na sledeće probleme, koje smo postepeno rešavali i rešavamo:

1. Sukob u interakciji različitih funkcija sistema. Na primjer, funkcija dodavanja transakcija u bazen blokirala je rad slanja i validacije blokova, i obrnuto, što je dovelo do pada brzine.

2. Nije odmah bilo jasno kako poslati ogroman broj transakcija uz minimiziranje troškova prijenosa podataka.

3. Nije bilo jasno kako i gdje pohraniti podatke da bi se postigli visoki rezultati.

4. Nije bilo jasno kako organizirati mrežu između čvorova, budući da veličina bloka sa 1 milion transakcija zauzima oko 100 MB.

5. Rad u jednonitnom režimu prekida vezu između čvorova kada dođe do dugih proračuna (na primjer, pravljenje Merkle stabla i izračunavanje njegovog hasha).

Kako smo se nosili sa svim ovim?

Prva verzija Plasma Cash čvora bila je neka vrsta kombinata koji je mogao raditi sve u isto vrijeme: prihvatiti transakcije, poslati i potvrditi blokove i pružiti API za pristup podacima. Budući da je NodeJS izvorno jednonit, teška funkcija izračunavanja Merkleovog stabla blokirala je funkciju dodavanja transakcije. Videli smo dve opcije za rešavanje ovog problema:

1. Pokrenite nekoliko NodeJS procesa, od kojih svaki obavlja određene funkcije.

2. Koristite worker_threads i premjestite izvršenje dijela koda u niti.

Kao rezultat toga, koristili smo obje opcije u isto vrijeme: logički smo podijelili jedan čvor na 3 dijela koji mogu raditi odvojeno, ali u isto vrijeme sinkrono

1. Čvor za podnošenje, koji prihvata transakcije u skup i kreira blokove.

2. Čvor za provjeru valjanosti koji provjerava valjanost čvorova.

3. API čvor - pruža API za pristup podacima.

U ovom slučaju, možete se povezati sa svakim čvorom preko unix utičnice koristeći cli.

Premjestili smo teške operacije, kao što je izračunavanje Merkleovog stabla, u zasebnu nit.

Time smo postigli normalan rad svih Plasma Cash funkcija istovremeno i bez kvarova.

Kada je sistem postao funkcionalan, počeli smo testirati brzinu i, nažalost, dobili smo nezadovoljavajuće rezultate: 5 transakcija u sekundi i do 000 transakcija po bloku. Morao sam da shvatim šta je pogrešno implementirano.

Za početak smo počeli da testiramo mehanizam komunikacije sa Plasma Cash-om kako bismo otkrili maksimalnu sposobnost sistema. Ranije smo pisali da Plasma Cash čvor pruža unix socket interfejs. U početku je bio baziran na tekstu. json objekti su poslani pomoću `JSON.parse()` i `JSON.stringify()`.

```json
{
  "action": "sendTransaction",
  "payload":{
    "prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0",
    "prevBlock": 41,
    "tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445",
    "newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4",
    "type": "pay",
    "data": "",
    "signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"
  }
}
```

Izmjerili smo brzinu prijenosa takvih objekata i pronašli ~ 130k u sekundi. Pokušali smo zamijeniti standardne funkcije za rad sa jsonom, ali performanse se nisu poboljšale. V8 motor mora biti dobro optimiziran za ove operacije.

Radili smo sa transakcijama, tokenima i blokovima kroz klase. Prilikom kreiranja takvih klasa, performanse su pale za 2 puta, što ukazuje da OOP nije prikladan za nas. Morao sam sve prepisati na čisto funkcionalan pristup.

Snimanje u bazi podataka

U početku je Redis izabran za skladištenje podataka kao jedno od najproduktivnijih rješenja koje zadovoljava naše zahtjeve: skladištenje ključ/vrijednost, rad sa hash tablicama, skupovima. Pokrenuli smo redis-benchmark i dobili ~80 operacija u sekundi u 1 načinu rada.

Za visoke performanse, finije smo podesili Redis:

  • Uspostavljena je unix socket veza.
  • Onemogućili smo spremanje stanja na disk (radi pouzdanosti, možete postaviti repliku i spremiti na disk u zasebnom Redis-u).

U Redis-u, skup je hash tablica jer moramo biti u mogućnosti da dohvatimo sve transakcije u jednom upitu i izbrišemo transakcije jednu po jednu. Pokušali smo koristiti redovnu listu, ali je sporije kada se učitava cijela lista.

Kada se koristi standardni NodeJS, Redis biblioteke su postigle performanse od 18 hiljada transakcija u sekundi. Brzina je pala 9 puta.

Pošto nam je benchmark pokazao da su mogućnosti očigledno 5 puta veće, počeli smo da optimizujemo. Promijenili smo biblioteku u ioredi i dobili performanse od 25k u sekundi. Dodavali smo transakcije jednu po jednu koristeći naredbu `hset`. Tako smo generirali mnogo upita u Redisu. Pojavila se ideja da se transakcije kombinuju u pakete i šalju jednom naredbom `hmset`. Rezultat je 32k u sekundi.

Iz nekoliko razloga, koje ćemo opisati u nastavku, radimo s podacima koristeći `Buffer` i, kako se ispostavilo, ako ih konvertujete u tekst (`buffer.toString('hex')`) prije pisanja, možete dobiti dodatne performanse. Tako je brzina povećana na 35k u sekundi. Trenutno smo odlučili da obustavimo dalju optimizaciju.

Morali smo se prebaciti na binarni protokol jer:

1. Sistem često izračunava hasheve, potpise itd., a za to su mu potrebni podaci u `Međuspremniku.

2. Kada se šalju između usluga, binarni podaci teže su manje od teksta. Na primjer, kada se šalje blok sa 1 milion transakcija, podaci u tekstu mogu zauzeti više od 300 megabajta.

3. Konstantna transformacija podataka utiče na performanse.

Stoga smo kao osnovu uzeli vlastiti binarni protokol za pohranjivanje i prijenos podataka, razvijen na bazi divne biblioteke `binary-data`.

Kao rezultat, dobili smo sljedeće strukture podataka:

—Transakcija

  ```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),
  }
  ```

—Blokiraj

  ```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,
  }
  ```

Sa uobičajenim naredbama `BD.encode(block, Protocol).slice();` i `BD.decode(buffer, Protocol)` konvertujemo podatke u `Buffer` za spremanje u Redis ili prosljeđivanje na drugi čvor i preuzimanje povrat podataka.

Također imamo 2 binarna protokola za prijenos podataka između servisa:

— Protokol za interakciju sa plazma čvorom preko unix socketa

  ```json
  {
    type: BD.types.uint8,
    messageId: BD.types.uint24le,
    error: BD.types.uint8,
    length: BD.types.uint24le,
    payload: BD.types.buffer(({node}) => node.length)
  }
  ```

gde:

  • `tip` — radnja koju treba izvršiti, na primjer, 1 — sendTransaction, 2 — getTransaction;
  • `korisni teret` — podatke koje je potrebno proslijediti odgovarajućoj funkciji;
  • `messageId` — ID poruke tako da se odgovor može identificirati.

— Protokol za interakciju između čvorova

  ```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)
  }
  ```

gde:

  • `šifra` — kod poruke, na primjer 6 — PRIPREMA_NOVO_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
  • `versionProtocol` — verzija protokola, budući da se čvorovi sa različitim verzijama mogu podići na mreži i mogu raditi drugačije;
  • `seq` — identifikator poruke;
  • `countChunk` и `chunkNumber` neophodno za razdvajanje velikih poruka;
  • `dužina` и `korisni teret` dužine i samih podataka.

Pošto smo prethodno ukucali podatke, konačni sistem je mnogo brži od Ethereumove `rlp` biblioteke. Nažalost, još nismo u mogućnosti da to odbijemo, jer je potrebno finalizirati smart ugovor, što planiramo i ubuduće.

Ako smo uspjeli postići brzinu 35 000 transakcija u sekundi, takođe ih moramo obraditi u optimalnom vremenu. Budući da približno vrijeme formiranja bloka traje 30 sekundi, moramo uključiti u blok 1 000 000 transakcije, što znači slanje više 100 MB podataka.

U početku smo koristili biblioteku `ethereumjs-devp2p` za komunikaciju između čvorova, ali ona nije mogla podnijeti toliko podataka. Kao rezultat toga, koristili smo `ws` biblioteku i konfigurirali slanje binarnih podataka putem websocketa. Naravno, nailazili smo i na probleme prilikom slanja velikih paketa podataka, ali smo ih podijelili na dijelove i sada su ti problemi nestali.

Takođe formiranje Merkleovog stabla i izračunavanje heša 1 000 000 transakcije zahtijeva oko 10 sekundi neprekidnog izračunavanja. Za to vrijeme, veza sa svim čvorovima uspijeva prekinuti. Odlučeno je da se ovaj proračun premjesti u posebnu nit.

Zaključci:

Zapravo, naša otkrića nisu nova, ali iz nekog razloga mnogi stručnjaci na njih zaboravljaju prilikom razvoja.

  • Korišćenje funkcionalnog programiranja umesto objektno orijentisanog programiranja poboljšava produktivnost.
  • Monolit je gori od servisne arhitekture za produktivan NodeJS sistem.
  • Korištenje `worker_threads` za teško računanje poboljšava odziv sistema, posebno kada se radi o I/o operacijama.
  • unix socket je stabilniji i brži od http zahtjeva.
  • Ako trebate brzo prenijeti velike podatke preko mreže, bolje je koristiti websockete i slati binarne podatke, podijeljene u dijelove, koji se mogu proslijediti ako ne stignu, a zatim spojiti u jednu poruku.

Pozivamo Vas da posjetite GitHub projekat: https://github.com/opporty-com/Plasma-Cash/tree/new-version

Članak je koautor Alexander Nashivan, viši programer Clever Solution Inc.

izvor: www.habr.com

Dodajte komentar