Blockchain: kokį PoC turėtume sukurti?

Tavo akys bijo, o rankos niežti!

Ankstesniuose straipsniuose nagrinėjome technologijas, kuriomis remiantis kuriamos blokų grandinės (Ką turėtume sukurti blokų grandinę?) ir atvejai, kuriuos galima įgyvendinti su jų pagalba (Kodėl turėtume kurti bylą?). Atėjo laikas dirbti savo rankomis! Norėdamas įgyvendinti pilotus ir PoC (Proof of Concept), norėčiau naudoti debesis, nes... juos galima pasiekti iš bet kurios pasaulio vietos ir dažnai nereikia gaišti laiko varginančiam aplinkos įrengimui, nes Yra iš anksto nustatytų konfigūracijų. Taigi, sukurkime ką nors paprasto, pavyzdžiui, monetų pervedimo tarp dalyvių tinklą ir kukliai pavadinkime jį Bitcoin. Tam naudosime IBM debesį ir universalią blockchain Hyperledger Fabric. Pirmiausia išsiaiškinkime, kodėl Hyperledger Fabric vadinamas universalia blokų grandine?

Blockchain: kokį PoC turėtume sukurti?

Hyperledger Fabric – universali blokų grandinė

Apskritai, universali informacinė sistema yra:

  • Serverių rinkinys ir programinės įrangos branduolys, atliekantis verslo logiką;
  • Sąsajos sąveikai su sistema;
  • Įrenginių/žmonių registravimo, autentifikavimo ir autorizacijos įrankiai;
  • Duomenų bazė, kurioje saugomi operatyviniai ir archyviniai duomenys:

Blockchain: kokį PoC turėtume sukurti?

Oficialią „Hyperledger Fabric“ versiją galite perskaityti adresu Dabar naršo, o trumpai tariant, Hyperledger Fabric yra atvirojo kodo platforma, leidžianti kurti privačias blokų grandines ir vykdyti savavališkas išmaniąsias sutartis, parašytas JS ir Go programavimo kalbomis. Išsamiai pažvelkime į „Hyperledger Fabric“ architektūrą ir įsitikinkime, kad tai universali sistema, turinti tik duomenų saugojimo ir įrašymo specifiką. Specifiškumas yra tas, kad duomenys, kaip ir visose blokų grandinėse, yra saugomi blokuose, kurie dedami į blokų grandinę tik tada, kai dalyviai pasiekia konsensusą ir įrašius duomenų negalima tyliai taisyti ar ištrinti.

„Hyperledger“ audinio architektūra

Diagramoje parodyta „Hyperledger Fabric“ architektūra:

Blockchain: kokį PoC turėtume sukurti?

Organizacijos — organizacijose yra bendraamžių, t.y. „blockchain“ egzistuoja dėl organizacijų paramos. Įvairios organizacijos gali būti vieno kanalo dalimi.

Kanalas — loginė struktūra, jungianti bendraamžius į grupes, t.y. nurodyta blokų grandinė. „Hyperledger Fabric“ gali vienu metu apdoroti kelias blokų grandines, turinčias skirtingą verslo logiką.

Narystės paslaugų teikėjas (MSP) yra CA (sertifikavimo institucija), išduodanti tapatybę ir priskirianti vaidmenis. Norėdami sukurti mazgą, turite bendrauti su MSP.

Lygiaverčiai mazgai - patikrinti sandorius, saugoti blokų grandinę, vykdyti išmaniąsias sutartis ir sąveikauti su programomis. Bendraamžiai turi tapatybę (skaitmeninį sertifikatą), kurį išduoda MSP. Skirtingai nuo Bitcoin arba Etherium tinklo, kuriame visi mazgai turi lygias teises, Hyperledger Fabric mazgai atlieka skirtingus vaidmenis:

  • Galbūt bendraamžis pritariantis bendraamžis (EP) ir vykdyti išmaniąsias sutartis.
  • Įsipareigojęs bendraamžis (CP) - išsaugokite duomenis tik blokų grandinėje ir atnaujinkite „Pasaulio būseną“.
  • Inkaro bendraamžis (AP) – jei blokų grandinėje dalyvauja kelios organizacijos, tada tarpusavio bendravimui naudojami inkaro partneriai. Kiekviena organizacija turi turėti vieną ar daugiau pagrindinių partnerių. Naudodamas AP, bet kuris organizacijos partneris gali gauti informacijos apie visus kitų organizacijų bendraamžius. Naudojamas informacijai tarp AP sinchronizuoti apkalbų protokolas.
  • Lyderis Peer — jei organizacija turi kelis bendraamžius, blokus iš Užsakymų tarnybos gaus ir kitiems bendraamžiams atiduos tik to partnerio vadovas. Lyderį gali nurodyti statiškai arba dinamiškai pasirinkti organizacijos bendraamžiai. Apkalbų protokolas taip pat naudojamas informacijai apie lyderius sinchronizuoti.

Aktyvai — subjektai, kurie turi vertę ir yra saugomi blokų grandinėje. Tiksliau tariant, tai yra JSON formato rakto vertės duomenys. Būtent šie duomenys yra įrašomi į Blockchain. Jie turi istoriją, kuri saugoma blokų grandinėje, ir dabartinę būseną, kuri saugoma „Pasaulio būsenos“ duomenų bazėje. Duomenų struktūros pildomos savavališkai, atsižvelgiant į verslo užduotis. Privalomų laukelių nėra, vienintelė rekomendacija – turtas turi turėti savininką ir būti vertingas.

buhalterijos didžioji knyga — susideda iš „Blockchain“ ir „Word“ būsenos duomenų bazės, kurioje saugoma dabartinė turto būklė. Pasaulio valstybė naudoja LevelDB arba CouchDB.

Protinga sutartis — naudojant išmaniąsias sutartis, įgyvendinama sistemos verslo logika. „Hyperledger Fabric“ išmaniosios sutartys vadinamos grandininiu kodu. Naudojant grandininį kodą, nurodomas turtas ir operacijos su jais. Techniniu požiūriu išmaniosios sutartys yra programinės įrangos moduliai, įdiegti JS arba Go programavimo kalbomis.

Patvirtinimo politika – kiekvienam grandinės kodui galite nustatyti politiką, kiek ir iš ko reikia tikėtis operacijos patvirtinimų. Jei politika nenustatyta, tada numatytasis nustatymas yra: „operaciją turi patvirtinti bet kurios kanalo organizacijos narys“. Politikos pavyzdžiai:

  • Sandoriui turi pritarti bet kuris organizacijos administratorius;
  • Turi patvirtinti bet kuris organizacijos narys ar klientas;
  • Turi būti patvirtinta bet kurios kolegos organizacijos.

Užsakymo paslauga - supakuoja operacijas į blokus ir siunčia juos bendraamžiams kanale. Garantuoja pranešimų pristatymą visiems bendraamžiams tinkle. Naudojamas pramoninėms sistemoms Kafka žinučių brokeris, kūrimui ir testavimui Solo.

CallFlow

Blockchain: kokį PoC turėtume sukurti?

  • Programa bendrauja su „Hyperledger Fabric“ naudodama „Go“, „Node.js“ arba „Java“ SDK;
  • Klientas sukuria tx operaciją ir siunčia ją patvirtinantiems kolegoms;
  • Partneris patikrina kliento parašą, užbaigia operaciją ir siunčia patvirtinimo parašą atgal klientui. Grandininis kodas vykdomas tik patvirtinančiame bendradarbyje, o jo vykdymo rezultatas siunčiamas visiems bendraamžiams. Šis darbo algoritmas vadinamas PBFT (Practical Byzantine Fault Tolerant) konsensusu. Skiriasi nuo klasikinis BFT tai, kad žinutė išsiųsta ir patvirtinimo laukiama ne iš visų dalyvių, o tik iš tam tikro rinkinio;
  • Klientas, gavęs patvirtinimo politiką atitinkantį atsakymų skaičių, siunčia operaciją Užsakymų tarnybai;
  • Užsakymo paslauga sukuria bloką ir siunčia jį visiems įsipareigojusiems partneriams. Užsakymo paslauga užtikrina nuoseklų blokų įrašymą, kuris pašalina vadinamąją knygos šakę (žiūrėkite skyrių "Šakės");
  • Bendraamžiai gauna bloką, dar kartą patikrina patvirtinimo politiką, įrašo bloką į blokų grandinę ir pakeičia būseną „World state“ DB.

Tie. Dėl to vaidmenys pasiskirsto tarp mazgų. Tai užtikrina, kad blokų grandinė yra keičiamo dydžio ir saugi:

  • Išmaniosios sutartys (grandinės kodas) patvirtina bendraamžius. Taip užtikrinamas išmaniųjų sutarčių konfidencialumas, nes jį saugo ne visi dalyviai, o tik pritariantys bendraamžiai.
  • Užsakymas turėtų veikti greitai. Tai užtikrina tai, kad „Ordering“ formuoja tik bloką ir siunčia jį fiksuotam lyderių bendraamžių rinkiniui.
  • Įsipareigoję bendraamžiai saugo tik blokų grandinę – jų gali būti daug ir jos nereikalauja daug energijos ir momentinio veikimo.

Daugiau informacijos apie Hyperledger Fabric architektūrinius sprendimus ir kodėl jis veikia taip, o ne kitaip, rasite čia: Architektūros ištakos arba čia: „Hyperledger Fabric“: paskirstyta operacinė sistema leidžiamoms blokų grandinėms.

Taigi, Hyperledger Fabric yra tikrai universali sistema, su kuria galite:

  • Įdiegti savavališką verslo logiką naudojant išmaniųjų sutarčių mechanizmą;
  • Įrašykite ir gaukite duomenis iš blockchain duomenų bazės JSON formatu;
  • Suteikite ir patvirtinkite API prieigą naudodami sertifikavimo įstaigą.

Dabar, kai šiek tiek supratome apie „Hyperledger Fabric“ specifiką, pagaliau padarykime ką nors naudingo!

Blockchain diegimas

Problemos teiginys

Užduotis yra įdiegti Citcoin tinklą su šiomis funkcijomis: susikurti sąskaitą, gauti likutį, papildyti savo sąskaitą, pervesti monetas iš vienos sąskaitos į kitą. Nubraižykime objekto modelį, kurį toliau diegsime išmaniojoje sutartyje. Taigi, turėsime sąskaitas, kurios identifikuojamos pagal pavadinimus ir kuriose yra likutis, ir sąskaitų sąrašą. Paskyros ir sąskaitų sąrašas yra „Hyperledger Fabric“ turto atžvilgiu. Atitinkamai, jie turi istoriją ir dabartinę būklę. Pabandysiu tai aiškiai nupiešti:

Blockchain: kokį PoC turėtume sukurti?

Didžiausi skaičiai yra dabartinė būsena, kuri saugoma „Pasaulio būsenos“ duomenų bazėje. Po jais yra skaičiai, rodantys istoriją, kuri yra saugoma blokų grandinėje. Dabartinę turto būklę keičia sandoriai. Turtas keičiasi tik kaip visuma, todėl po operacijos sukuriamas naujas objektas, o dabartinė turto vertė patenka į istoriją.

IBM debesis

Mes sukuriame paskyrą IBM debesis. Norint naudoti „blockchain“ platformą, ji turi būti atnaujinta į „Pay-As-You-Go“. Šis procesas gali būti ne greitas, nes... IBM prašo papildomos informacijos ir ją patikrina rankiniu būdu. Teigiamai galiu pasakyti, kad IBM turi gerą mokomąją medžiagą, leidžiančią įdiegti „Hyperledger Fabric“ jų debesyje. Man patiko šios straipsnių ir pavyzdžių serijos:

Toliau pateikiamos IBM Blockchain platformos ekrano kopijos. Tai ne instrukcija, kaip sukurti blokų grandinę, o tiesiog užduoties apimties demonstravimas. Taigi savo tikslams sudarome vieną organizaciją:

Blockchain: kokį PoC turėtume sukurti?

Jame kuriame mazgus: Orderer CA, Org1 CA, Orderer Peer:

Blockchain: kokį PoC turėtume sukurti?

Kuriame vartotojus:

Blockchain: kokį PoC turėtume sukurti?

Sukurkite kanalą ir pavadinkite jį citcoin:

Blockchain: kokį PoC turėtume sukurti?

Iš esmės „Channel“ yra „blockchain“, todėl jis prasideda nuo nulio bloko (Genesis blokas):

Blockchain: kokį PoC turėtume sukurti?

Protingos sutarties rašymas

/*
 * Citcoin smart-contract v1.5 for Hyperledger Fabric
 * (c) Alexey Sushkov, 2019
 */
 
'use strict';
 
const { Contract } = require('fabric-contract-api');
const maxAccounts = 5;
 
class CitcoinEvents extends Contract {
 
    async instantiate(ctx) {
        console.info('instantiate');
        let emptyList = [];
        await ctx.stub.putState('accounts', Buffer.from(JSON.stringify(emptyList)));
    }
    // Get all accounts
    async GetAccounts(ctx) {
        // Get account list:
        let accounts = '{}'
        let accountsData = await ctx.stub.getState('accounts');
        if (accountsData) {
            accounts = JSON.parse(accountsData.toString());
        } else {
            throw new Error('accounts not found');
        }
        return accountsData.toString()
    }
     // add a account object to the blockchain state identifited by their name
    async AddAccount(ctx, name, balance) {
        // this is account data:
        let account = {
            name: name,
            balance: Number(balance),       
            type: 'account',
        };
        // create account:
        await ctx.stub.putState(name, Buffer.from(JSON.stringify(account)));
 
        // Add account to list:
        let accountsData = await ctx.stub.getState('accounts');
        if (accountsData) {
            let accounts = JSON.parse(accountsData.toString());
            if (accounts.length < maxAccounts)
            {
                accounts.push(name);
                await ctx.stub.putState('accounts', Buffer.from(JSON.stringify(accounts)));
            } else {
                throw new Error('Max accounts number reached');
            }
        } else {
            throw new Error('accounts not found');
        }
        // return  object
        return JSON.stringify(account);
    }
    // Sends money from Account to Account
    async SendFrom(ctx, fromAccount, toAccount, value) {
        // get Account from
        let fromData = await ctx.stub.getState(fromAccount);
        let from;
        if (fromData) {
            from = JSON.parse(fromData.toString());
            if (from.type !== 'account') {
                throw new Error('wrong from type');
            }   
        } else {
            throw new Error('Accout from not found');
        }
        // get Account to
        let toData = await ctx.stub.getState(toAccount);
        let to;
        if (toData) {
            to = JSON.parse(toData.toString());
            if (to.type !== 'account') {
                throw new Error('wrong to type');
            }  
        } else {
            throw new Error('Accout to not found');
        }
 
        // update the balances
        if ((from.balance - Number(value)) >= 0 ) {
            from.balance -= Number(value);
            to.balance += Number(value);
        } else {
            throw new Error('From Account: not enought balance');          
        }
 
        await ctx.stub.putState(from.name, Buffer.from(JSON.stringify(from)));
        await ctx.stub.putState(to.name, Buffer.from(JSON.stringify(to)));
                 
        // define and set Event
        let Event = {
            type: "SendFrom",
            from: from.name,
            to: to.name,
            balanceFrom: from.balance,
            balanceTo: to.balance,
            value: value
        };
        await ctx.stub.setEvent('SendFrom', Buffer.from(JSON.stringify(Event)));
 
        // return to object
        return JSON.stringify(from);
    }
 
    // get the state from key
    async GetState(ctx, key) {
        let data = await ctx.stub.getState(key);
        let jsonData = JSON.parse(data.toString());
        return JSON.stringify(jsonData);
    }
    // GetBalance   
    async GetBalance(ctx, accountName) {
        let data = await ctx.stub.getState(accountName);
        let jsonData = JSON.parse(data.toString());
        return JSON.stringify(jsonData);
    }
     
    // Refill own balance
    async RefillBalance(ctx, toAccount, value) {
        // get Account to
        let toData = await ctx.stub.getState(toAccount);
        let to;
        if (toData) {
            to = JSON.parse(toData.toString());
            if (to.type !== 'account') {
                throw new Error('wrong to type');
            }  
        } else {
            throw new Error('Accout to not found');
        }
 
        // update the balance
        to.balance += Number(value);
        await ctx.stub.putState(to.name, Buffer.from(JSON.stringify(to)));
                 
        // define and set Event
        let Event = {
            type: "RefillBalance",
            to: to.name,
            balanceTo: to.balance,
            value: value
        };
        await ctx.stub.setEvent('RefillBalance', Buffer.from(JSON.stringify(Event)));
 
        // return to object
        return JSON.stringify(from);
    }
}
module.exports = CitcoinEvents;

Intuityviai čia viskas turėtų būti aišku:

  • Yra keletas funkcijų (AddAccount, GetAccounts, SendFrom, GetBalance, RefillBalance), kurias demonstracinė programa iškvies naudodama Hyperledger Fabric API.
  • Funkcijos SendFrom ir RefillBalance generuoja įvykius, kuriuos gaus demonstracinė programa.
  • Instantiavimo funkcija iškviečiama vieną kartą, kai yra išmanioji sutartis. Tiesą sakant, jis vadinamas ne vieną kartą, o kiekvieną kartą, kai keičiasi išmaniosios sutarties versija. Todėl sąrašo inicijavimas tuščiu masyvu yra bloga idėja, nes Dabar, kai pakeisime išmaniosios sutarties versiją, prarasime dabartinį sąrašą. Bet viskas gerai, aš tik mokausi).
  • Paskyros ir paskyrų sąrašas yra JSON duomenų struktūros. JS naudojamas duomenų apdorojimui.
  • Dabartinę turto vertę galite gauti naudodami funkcijos getState iškvietimą ir atnaujinti naudodami putState.
  • Kuriant Paskyrą iškviečiama AddAccount funkcija, kurioje lyginamas maksimalus blokų grandinėje esančių paskyrų skaičius (maxAccounts = 5). Ir čia yra stakta (ar pastebėjote?), dėl kurios be galo daugėja paskyrų. Reikėtų vengti tokių klaidų)

Tada įkeliame išmaniąją sutartį į kanalą ir ją sukuriame:

Blockchain: kokį PoC turėtume sukurti?

Pažvelkime į „Smart Contract“ diegimo operaciją:

Blockchain: kokį PoC turėtume sukurti?

Pažiūrėkime išsamią informaciją apie mūsų kanalą:

Blockchain: kokį PoC turėtume sukurti?

Dėl to gauname šią „blockchain“ tinklo schemą IBM debesyje. Diagramoje taip pat parodyta demonstracinė programa, veikianti „Amazon“ debesyje virtualiame serveryje (daugiau apie tai kitame skyriuje):

Blockchain: kokį PoC turėtume sukurti?

„Hyperledger Fabric“ API skambučių GUI kūrimas

„Hyperledger Fabric“ turi API, kurią galima naudoti:

  • Sukurti kanalą;
  • Ryšiai su kanalu;
  • Išmaniųjų sutarčių diegimas ir realizavimas kanale;
  • Skambinimo operacijos;
  • Prašyti informacijos apie blokų grandinę.

Programų kūrimas

Demonstracinėje programoje API naudosime tik norėdami iškviesti operacijas ir prašyti informacijos, nes Likusius veiksmus jau atlikome naudodami IBM blockchain platformą. Rašome GUI naudodami standartinį technologijų krūvą: Express.js + Vue.js + Node.js. Galite parašyti atskirą straipsnį apie tai, kaip pradėti kurti modernias žiniatinklio programas. Čia paliksiu nuorodą į man labiausiai patikusį paskaitų ciklą: „Full Stack“ žiniatinklio programa naudojant „Vue.js“ ir „Express.js“.. Rezultatas yra kliento-serverio programa su pažįstama grafine sąsaja Google Material Design stiliumi. REST API tarp kliento ir serverio susideda iš kelių skambučių:

  • HyperledgerDemo/v1/init – inicijuokite blokų grandinę;
  • HyperledgerDemo/v1/accounts/list — gauti visų paskyrų sąrašą;
  • HyperledgerDemo/v1/account?name=Bob&balance=100 — sukurti Bobo paskyrą;
  • HyperledgerDemo/v1/info?account=Bob — gauti informacijos apie Bob paskyrą;
  • HyperledgerDemo/v1/transaction?from=Bob&to=Alice&volume=2 – perkelkite dvi monetas iš Bobo Alisai;
  • HyperledgerDemo/v1/disconnect – uždarykite ryšį su blokų grandine.

API aprašymas su pavyzdžiais, įtrauktais į Paštininko svetainė - gerai žinoma programa, skirta HTTP API testavimui.

Demonstracinė programa „Amazon“ debesyje

Įkėliau programą į „Amazon“, nes... IBM vis dar negalėjo atnaujinti mano paskyros ir leisti kurti virtualius serverius. Kaip pridėti vyšnią prie domeno: www.citcoin.info. Palaikysiu serverį kurį laiką įjungtą, tada išjungsiu, nes... varva centai už nuomą, o citkoinų monetos dar neįtrauktos į biržą) Straipsnyje įdedu demo ekrano kopijas, kad būtų aiški darbo logika. Demonstracinė programa gali:

  • Inicijuoti blokų grandinę;
  • Susikurti Paskyrą (tačiau dabar naujos Paskyros kurti negalite, nes blokų grandinėje pasiektas maksimalus išmaniojoje sutartyje nurodytas paskyrų skaičius);
  • Gauti sąskaitų sąrašą;
  • Perkelkite citcoin monetas tarp Alisos, Bobo ir Alekso;
  • Gauti įvykius (tačiau dabar nėra galimybės rodyti įvykių, todėl dėl paprastumo sąsaja sako, kad įvykiai nepalaikomi);
  • Žurnalo veiksmai.

Pirmiausia inicijuojame blokų grandinę:

Blockchain: kokį PoC turėtume sukurti?

Tada sukuriame savo sąskaitą, nešvaistydami laiko su likučiu:

Blockchain: kokį PoC turėtume sukurti?

Gauname visų galimų paskyrų sąrašą:

Blockchain: kokį PoC turėtume sukurti?

Mes pasirenkame siuntėją ir gavėją ir gauname jų likučius. Jei siuntėjas ir gavėjas yra tas pats, tada jo sąskaita bus papildyta:

Blockchain: kokį PoC turėtume sukurti?

Žurnale stebime operacijų vykdymą:

Blockchain: kokį PoC turėtume sukurti?

Tiesą sakant, visa tai yra demonstracinėje programoje. Žemiau galite pamatyti mūsų sandorį blokų grandinėje:

Blockchain: kokį PoC turėtume sukurti?

Ir bendras operacijų sąrašas:

Blockchain: kokį PoC turėtume sukurti?

Taip sėkmingai užbaigėme PoC, kad sukurtume Citcoin tinklą. Ką dar reikia padaryti, kad „Citcoin“ taptų visaverčiu monetų pervedimo tinklu? Labai mažai:

  • Paskyros kūrimo etape įdiekite privataus / viešo rakto generavimą. Privatus raktas turi būti saugomas pas paskyros vartotoją, viešasis raktas turi būti saugomas blokų grandinėje.
  • Atlikite monetų pervedimą, kai vartotojui identifikuoti naudojamas viešasis raktas, o ne vardas.
  • Šifruokite operacijas, einančias iš vartotojo į serverį, naudodami jo privatų raktą.

išvada

Įdiegėme Citcoin tinklą su šiomis funkcijomis: pridėti sąskaitą, gauti likutį, papildyti savo sąskaitą, pervesti monetas iš vienos sąskaitos į kitą. Taigi, kiek mums kainavo sukurti PoC?

  • Jums reikia studijuoti blokų grandinę apskritai ir ypač Hyperledger Fabric;
  • Išmok naudotis IBM arba Amazon debesimis;
  • Išmokti JS programavimo kalbą ir tam tikrą žiniatinklio sistemą;
  • Jei kai kuriuos duomenis reikia saugoti ne blokų grandinėje, o atskiroje duomenų bazėje, tai išmokite integruoti, pavyzdžiui, su PostgreSQL;
  • Ir paskutinis, bet ne mažiau svarbus dalykas - jūs negalite gyventi šiuolaikiniame pasaulyje be žinių apie Linux!)

Žinoma, tai nėra raketų mokslas, bet jūs turėsite sunkiai dirbti!

Šaltiniai GitHub

Uždėti šaltiniai GitHub. Trumpas saugyklos aprašymas:
Katalogas «serveris» — Node.js serveris
Katalogas «klientas» — Node.js klientas
Katalogas «blockchain"(parametrų reikšmės ir raktai, žinoma, neveikia ir pateikiami tik kaip pavyzdys):

  • sutartis – išmaniosios sutarties šaltinio kodas
  • piniginė – vartotojo raktai, skirti naudoti Hyperledger Fabric API.
  • *.cds – sukompiliuotos išmaniųjų sutarčių versijos
  • *.json failai – konfigūracijos failų, skirtų naudoti Hyperledger Fabric API, pavyzdžiai

Tai tik pradžia!

Šaltinis: www.habr.com

Добавить комментарий