Bagaimana saya menulis ulang mesin pencari penerbangan dari PHP ke NodeJS

Hai Nama saya Andrey, saya mahasiswa pascasarjana di salah satu universitas teknik di Moskow dan paruh waktu sangat sederhana pengusaha dan pengembang pemula. Dalam artikel ini, saya memutuskan untuk berbagi pengalaman saya beralih dari PHP (yang dulu saya suka karena kesederhanaannya, tetapi akhirnya menjadi dibenci oleh saya - saya menjelaskan mengapa di bawah potongan) ke NodeJS. Tugas yang sangat sepele dan tampaknya elementer dapat diberikan di sini, yang, bagaimanapun, secara pribadi saya ingin menyelesaikannya selama berkenalan dengan NodeJS dan fitur pengembangan sisi server dalam JavaScript. Saya akan mencoba menjelaskan dan menunjukkan dengan jelas bahwa PHP akhirnya pergi ke matahari terbenam dan telah memberi jalan kepada NodeJS. Mungkin bahkan akan berguna bagi seseorang untuk mempelajari beberapa fitur rendering halaman HTML di Node, yang pada awalnya tidak disesuaikan dengan ini dari kata sama sekali.


Pendahuluan


Saat menulis mesin, saya menggunakan teknik yang paling sederhana. Tidak ada pengelola paket, tidak ada perutean. Hanya folder hardcore, yang namanya cocok dengan rute yang diminta, dan index.php di masing-masingnya, yang dikonfigurasi oleh PHP-FPM untuk mendukung kumpulan proses. Kemudian menjadi perlu untuk menggunakan Komposer dan Laravel, yang merupakan jerami terakhir bagi saya. Sebelum beralih ke kisah mengapa saya bahkan memutuskan untuk menulis ulang semuanya dari PHP ke NodeJS, saya akan memberi tahu Anda sedikit tentang latar belakangnya.


Manajer paket


Pada akhir 2018, saya bekerja dengan satu proyek yang ditulis dalam Laravel. Itu perlu untuk memperbaiki beberapa bug, membuat perubahan pada fungsi yang ada, menambahkan beberapa tombol baru di antarmuka. Prosesnya dimulai dengan menginstal paket dan dependensi manager. Dalam PHP, Komposer digunakan untuk ini. Kemudian pelanggan menyediakan server dengan 1 core dan RAM 512 megabyte dan ini adalah pengalaman pertama saya dengan Composer. Saat memasang dependensi pada server pribadi virtual dengan memori 512 megabita, proses macet karena kurangnya memori.


Apa?


Bagi saya, sebagai orang yang akrab dengan Linux dan berpengalaman dalam bekerja dengan Debian dan Ubuntu, solusi untuk masalah ini jelas - menginstal file SWAP (file swap - bagi mereka yang tidak terbiasa dengan administrasi Linux). Seorang pengembang pemula yang belum berpengalaman yang memasang distribusi Laravel pertamanya di Digital Ocean, misalnya, hanya pergi ke panel kontrol dan menaikkan tarif hingga pemasangan dependensi berhenti dengan kesalahan segmentasi memori. Bagaimana dengan NodeJS?
Dan NodeJS memiliki manajer paket sendiri - npm. Ini jauh lebih mudah digunakan, lebih kompak, dapat bekerja bahkan di lingkungan dengan jumlah minimum RAM. Secara umum, tidak ada yang bisa disalahkan Komposer terhadap latar belakang NPM, namun, jika ada kesalahan selama instalasi paket, Komposer akan mogok seperti aplikasi PHP biasa dan Anda tidak akan pernah tahu bagian mana dari paket yang berhasil diinstal dan apakah itu diinstal pada akhirnya. berakhir. Secara umum, untuk administrator Linux, instalasi yang macet = kilas balik dalam Mode Penyelamatan dan dpkg --configure -a . Pada saat "kejutan" seperti itu menyalip saya, saya tidak menyukai PHP, tetapi ini adalah paku terakhir dalam peti mati cinta saya yang dulu sangat bagus untuk PHP.


Dukungan jangka panjang dan masalah versi


Ingat hype dan keheranan apa yang menyebabkan PHP7 saat pengembang pertama kali mempresentasikannya? Tingkatkan produktivitas lebih dari 2 kali, dan di beberapa komponen hingga 5 kali! Ingat kapan versi ketujuh PHP lahir? Dan seberapa cepat WordPress menghasilkan! Itu Desember 2015. Tahukah Anda bahwa PHP 7.0 sekarang dianggap sebagai versi PHP yang sudah usang dan sangat disarankan untuk memperbaruinya ... Tidak, bukan ke versi 7.1, tetapi ke versi 7.2. Menurut pengembang, versi 7.1 sudah kehilangan dukungan aktif dan hanya menerima pembaruan keamanan. Dan setelah 8 bulan ini akan berhenti. Ini akan berhenti, bersama dengan dukungan aktif dan versi 7.2. Ternyata pada akhir tahun ini, PHP hanya akan memiliki satu versi saat ini - 7.3.


Versi PHP saat ini


Sebenarnya, ini tidak akan menjadi rewel dan saya tidak akan mengaitkan ini dengan alasan keberangkatan saya dari PHP jika proyek yang saya tulis di PHP 7.0. * Sudah tidak menyebabkan peringatan penghentian ketika saya membukanya. Mari kita kembali ke proyek tempat pemasangan dependensi macet. Ini adalah proyek yang ditulis pada tahun 2015 di Laravel 4 dengan PHP 5.6. Tampaknya hanya 4 tahun yang telah berlalu, tetapi tidak - banyak peringatan penghentian, modul yang sudah ketinggalan zaman, ketidakmampuan untuk meningkatkan ke Laravel 5 secara normal karena banyak pembaruan engine root.


Dan ini tidak hanya berlaku untuk Laravel. Cobalah untuk mendapatkan aplikasi PHP yang ditulis selama dukungan aktif dari versi pertama PHP 7.0 dan bersiaplah untuk menghabiskan malam Anda mencari solusi untuk masalah yang muncul dalam modul PHP yang sudah ketinggalan zaman. Akhirnya, fakta yang menarik: dukungan untuk PHP 7.0 dihentikan lebih awal daripada dukungan untuk PHP 5.6. Untuk sesaat.


Bagaimana dengan NodeJS? Saya tidak akan mengatakan bahwa semuanya jauh lebih baik di sini dan periode dukungan untuk NodeJS pada dasarnya berbeda dari PHP. Tidak, ini hampir sama di sini - setiap versi LTS didukung selama 3 tahun. Tetapi NodeJS memiliki sedikit lebih banyak dari versi terbaru ini.


Versi NodeJS saat ini


Jika Anda perlu menggunakan aplikasi yang ditulis pada tahun 2016, maka pastikan Anda sama sekali tidak memiliki masalah dengan ini. Kebetulan, versi 6. * tidak akan lagi didukung hanya pada bulan April tahun ini. Dan di depan ada 8, 10, 11 dan 12 mendatang.


Kesulitan dan kejutan ketika beralih ke NodeJS


Saya akan mulai, mungkin, dengan pertanyaan paling menarik bagi saya tentang cara merender halaman HTML di NodeJS. Tapi pertama-tama mari kita ingat bagaimana hal ini dilakukan dalam PHP:


  1. Cantumkan HTML secara langsung dalam kode PHP. Begitu juga semua pemula yang belum mencapai MVC. Dan itu dilakukan di WordPress, yang benar-benar mengerikan.
  2. Gunakan MVC, yang seharusnya menyederhanakan interaksi pengembang dan menyediakan semacam membagi proyek menjadi beberapa bagian, tetapi pada kenyataannya pendekatan ini hanya memperumit semuanya pada waktu-waktu tertentu.
  3. Gunakan mesin template. Opsi paling nyaman, tetapi tidak di PHP. Lihat saja sintaks yang disarankan di Twig atau Blade dengan kurung kurawal dan persentase.

Saya adalah lawan yang gigih menggabungkan atau menggabungkan beberapa teknologi bersama. HTML harus ada secara terpisah, gaya untuk itu secara terpisah, JavaScript secara terpisah (dalam Bereaksi, ini umumnya terlihat mengerikan - HTML dan JavaScript dicampur). Itulah sebabnya opsi ideal untuk pengembang dengan preferensi seperti milik saya adalah mesin templat. Saya tidak perlu mencarinya untuk aplikasi web di NodeJS untuk waktu yang lama dan saya memilih Jade (PugJS). Hanya menghargai kesederhanaan sintaksnya:


  div.row.links div.col-lg-3.col-md-3.col-sm-4 h4.footer-heading . div.copyright div.copy-text 2017 - #{current_year} . div.contact-link span : a(href='mailto:hello@flaut.ru') hello@flaut.ru 

Semuanya sangat sederhana di sini: Saya menulis sebuah templat, mengunduhnya ke dalam aplikasi, mengompilasinya satu kali dan kemudian menggunakannya di tempat yang nyaman pada waktu yang nyaman. Menurut pendapat saya, kinerja PugJS sekitar 2 kali lebih baik daripada rendering dengan menanamkan HTML dalam kode PHP. Jika sebelumnya di PHP halaman statis dihasilkan oleh server dalam sekitar 200-250 milidetik, sekarang kali ini sekitar 90-120 milidetik (kita tidak berbicara tentang rendering dalam PugJS, tetapi tentang waktu yang diambil dari permintaan halaman ke respons server kepada klien dengan HTML siap) ) Beginilah tampilan dan kompilasi templat dan komponennya pada tahap peluncuran aplikasi seperti:


 const pugs = {} fs.readdirSync(__dirname + '/templates/').forEach(file => { if(file.endsWith('.pug')) { try { var filepath = __dirname + '/templates/' + file pugs[file.split('.pug')[0]] = pug.compile(fs.readFileSync(filepath, 'utf-8'), { filename: filepath }) } catch(e) { console.error(e) } } }) //       return pugs.tickets({ ...config }) 

Ini terlihat sangat sederhana, tetapi dengan Jade ada sedikit kerumitan pada tahap bekerja dengan HTML yang sudah dikompilasi. Faktanya adalah bahwa untuk mengimplementasikan skrip pada halaman, fungsi asinkron digunakan, yang mengambil semua file .js dari direktori dan menambahkan tanggal perubahan terakhir mereka ke masing-masing. Fungsi ini memiliki bentuk sebagai berikut:


 for(let i = 0; i < files.length; i++) { let period = files[i].lastIndexOf('.') // get last dot in filename let filename = files[i].substring(0, period) let extension = files[i].substring(period + 1) if(extension === 'js') { let fullFilename = filename + '.' + extension if(env === 'production') { scripts.push({ path: paths.production.web + fullFilename, mtime: await getMtime(paths.production.code + fullFilename)}) } else { if(files[i].startsWith('common') || files[i].startsWith('search')) { scripts.push({ path: paths.developer.scripts.web + fullFilename, mtime: await getMtime(paths.developer.scripts.code + fullFilename)}) } else { scripts.push({ path: paths.developer.vendor.web + fullFilename, mtime: await getMtime(paths.developer.vendor.code + fullFilename)}) } } } } 

Pada output, kita mendapatkan array objek dengan dua properti - path ke file dan waktu terakhir diedit di timestamp (untuk memperbarui cache klien). Masalahnya adalah bahwa bahkan pada tahap pengumpulan file skrip dari direktori, mereka semua dimuat ke dalam memori secara alfabetis (karena mereka berada di direktori itu sendiri, dan file dikumpulkan di dalamnya dari atas ke bawah - dari yang pertama ke yang terakhir). Ini mengarah pada fakta bahwa file app.js dimuat pertama kali, dan setelah itu datang file core.min.js dengan polyfill, dan vendor.min.js di bagian paling akhir. Masalah ini diselesaikan dengan cukup sederhana - penyortiran sangat dangkal:


 scripts.sort((a, b) => { if(a.path.includes('core.min.js')) { return -1 } else if(a.path.includes('vendor.min.js')) { return 0 } return 1 }) 

Di PHP, semuanya memiliki tampilan mengerikan dalam bentuk path ke file JS yang ditulis sebelumnya dalam sebuah string. Sederhana tapi tidak praktis.


NodeJS menyimpan aplikasinya dalam RAM


Ini merupakan nilai tambah yang besar. Semuanya diatur untuk saya sehingga pada server secara paralel dan independen satu sama lain ada dua situs terpisah - versi untuk pengembang dan versi produksi. Bayangkan saya membuat beberapa perubahan pada file PHP di situs pengembangan dan saya perlu meluncurkan perubahan ini untuk produksi. Untuk melakukan ini, Anda harus menghentikan server atau meletakkan rintisan "maaf, tech. Work" dan saat ini salin file satu per satu dari folder pengembang ke folder produksi. Ini menyebabkan semacam downtime dan dapat mengakibatkan hilangnya konversi. Keuntungan dari aplikasi dalam memori di NodeJS bagi saya adalah bahwa semua perubahan pada file engine akan dilakukan hanya setelah reboot. Ini sangat nyaman, karena Anda dapat menyalin semua file yang diperlukan dengan perubahan dan hanya kemudian me-restart server. Prosesnya tidak lebih dari 1-2 detik dan tidak menyebabkan downtime.
Pendekatan yang sama digunakan dalam nginx, misalnya. Anda pertama-tama mengedit konfigurasi, periksa dengan nginx -t dan baru kemudian melakukan perubahan dengan service nginx reload


Clustering Aplikasi NodeJS


NodeJS memiliki alat yang sangat nyaman - manajer proses pm2 . Bagaimana biasanya kita menjalankan aplikasi di Node? Kami masuk ke konsol dan menulis node index.js . Segera setelah kami menutup konsol, aplikasi ditutup. Setidaknya inilah yang terjadi pada server dengan Ubuntu. Untuk menghindari hal ini dan menjaga aplikasi tetap berjalan, tambahkan saja ke pm2 dengan pm2 start index.js --name production sederhana pm2 start index.js --name production perintah. Tapi itu belum semuanya. Alat ini memungkinkan pemantauan ( pm2 monit ) dan pengelompokan aplikasi.


Mari kita ingat bagaimana proses diatur dalam PHP. Misalkan kita memiliki permintaan nginx yang melayani http dan kita harus meneruskan permintaan itu ke PHP. Anda bisa melakukan ini secara langsung dan kemudian dengan setiap permintaan proses PHP baru akan muncul, dan ketika selesai, itu akan dibunuh. Atau Anda dapat menggunakan server fastcgi. Saya pikir semua orang tahu apa itu dan tidak perlu masuk ke detail, tapi kalau-kalau, saya akan mengklarifikasi bahwa PHP-FPM paling sering digunakan sebagai fastcgi dan tugasnya adalah menelurkan banyak proses PHP yang siap menerima dan memproses permintaan baru kapan saja. Apa kerugian dari pendekatan ini?


Yang pertama adalah Anda tidak pernah tahu berapa banyak memori yang akan dikonsumsi aplikasi Anda. Kedua, Anda akan selalu dibatasi dalam jumlah maksimum proses, dan karenanya, dengan lonjakan tajam dalam lalu lintas, aplikasi PHP Anda akan menggunakan semua memori dan kerusakan yang tersedia, atau bersandar pada batas proses yang diijinkan dan mulai membunuh yang lama. Ini dapat dicegah dengan menetapkan Saya tidak ingat parameter mana dalam file konfigurasi PHP-FPM yang dinamis dan kemudian banyak proses akan muncul sebagaimana diperlukan saat ini. Tetapi sekali lagi, serangan dasar DDoS akan memakan semua RAM dan menempatkan server Anda. Atau, misalnya, skrip bug akan memakan semua RAM dan server akan membeku untuk beberapa waktu (ada preseden dalam proses pengembangan).


Perbedaan mendasar dalam NodeJS adalah bahwa aplikasi tidak dapat mengkonsumsi lebih dari 1,5 gigabytes RAM. Tidak ada batasan proses, hanya ada batas memori. Ini mendorong Anda untuk menulis program seringan mungkin. Selain itu, sangat sederhana untuk menghitung jumlah cluster yang kami mampu, tergantung pada sumber daya CPU yang tersedia. Disarankan agar tidak lebih dari satu cluster digantung pada setiap inti (persis seperti di nginx, tidak lebih dari satu pekerja per inti CPU).


Pengelompokan dalam PM2


Keuntungan dari pendekatan ini adalah bahwa PM2 memuat kembali semua cluster secara bergantian. Kembali ke paragraf sebelumnya, yang berbicara tentang downtime 1-2 detik saat reboot. Dalam Mode-Cluster, ketika Anda me-restart server, aplikasi Anda tidak akan mengalami downtime milidetik.


NodeJS adalah pisau Swiss yang bagus


Sekarang ada situasi seperti itu ketika PHP bertindak sebagai bahasa untuk menulis situs, dan Python bertindak sebagai alat untuk merayapi situs ini. NodeJS adalah 2 in 1, di satu sisi adalah garpu, di sisi lain adalah sendok. Anda dapat menulis aplikasi dan perayap web yang cepat dan tangguh di server yang sama dalam aplikasi yang sama. Kedengarannya menggoda. Tetapi bagaimana ini bisa diwujudkan, Anda bertanya? Google sendiri meluncurkan Chromium API resmi - Puppeteer. Anda dapat meluncurkan Chrome Tanpa Kepala (peramban tanpa antarmuka pengguna - "tanpa kepala" Chrome) dan mendapatkan akses seluas mungkin ke API peramban untuk merayapi laman. Cara paling sederhana dan paling mudah digunakan untuk bekerja dengan Dalang .


Misalnya, dalam grup VKontakte kami ada posting diskon dan penawaran khusus secara teratur ke berbagai tujuan dari kota-kota CIS. Kami menghasilkan gambar untuk posting dalam mode otomatis, dan untuk membuatnya indah, kami membutuhkan gambar yang indah. Saya tidak ingin mengikat ke berbagai API dan membuat akun di banyak situs, jadi saya menulis aplikasi sederhana yang meniru pengguna biasa dengan browser Google Chrome yang berjalan di sekitar situs dengan gambar stok dan mengambil secara acak gambar yang ditemukan oleh kata kunci. Saya dulu menggunakan Python dan BeautifulSoup untuk ini, tetapi sekarang ini tidak lagi diperlukan. Dan fitur utama dan keunggulan Puppeteer adalah Anda dapat dengan mudah menyontek bahkan situs SPA, karena Anda dapat menggunakan browser lengkap yang memahami dan mengeksekusi kode JavaScript di situs. Sangat sederhana:


 const browser = await puppeteer.launch({headless: true, args:['--no-sandbox']}) const page = (await browser.pages())[0] await page.goto(`https://pixabay.com/photos/search/${imageKeyword}/?cat=buildings&orientation=horizontal`, { waitUntil: 'networkidle0' }) 

Jadi, dalam 3 baris kode, kami meluncurkan browser dan membuka halaman situs dengan gambar stok. Sekarang kita dapat memilih blok acak dengan gambar pada halaman dan menambahkan kelas untuk itu, di mana nanti kita bisa berbelok dengan cara yang sama dan pergi ke halaman secara langsung dengan gambar itu sendiri untuk memuat lebih lanjut:


 var imagesLength = await page.evaluate(() => { var photos = document.querySelectorAll('.search_results > .item') if(photos.length > 0) { photos[Math.floor(Math.random() * photos.length)].className += ' --anomaly_selected' } return photos.length }) 

Ingat berapa banyak kode yang diperlukan untuk menulis ini di PhantomJS (yang, kebetulan, menutup dan mengadakan kerja sama erat dengan tim pengembangan Dalang). Bisakah alat yang luar biasa seperti itu menghentikan siapa pun dari beralih ke NodeJS?


NodeJS menyediakan asynchrony dasar


Ini dapat dianggap sebagai keuntungan besar NodeJS dan JavaScript, terutama dengan munculnya async / menunggu di ES2017. Tidak seperti PHP, di mana panggilan apa pun dilakukan secara sinkron. Saya akan memberikan contoh sederhana. Sebelumnya, di mesin pencari, halaman dihasilkan di server, tetapi sesuatu harus ditampilkan pada halaman yang sudah ada di klien menggunakan JavaScript, dan pada saat itu Yandex belum dapat menggunakan JavaScript di situs web dan harus menerapkan mekanisme snapshot (snapshot halaman) khusus untuk itu. menggunakan Prerender. Snapshots disimpan di server kami dan dikeluarkan ke robot berdasarkan permintaan. Dilema adalah bahwa gambar-gambar ini dihasilkan dalam 3-5 detik, yang sama sekali tidak dapat diterima dan dapat mempengaruhi peringkat situs dalam hasil pencarian. Untuk mengatasi masalah ini, sebuah algoritma sederhana ditemukan: ketika robot meminta beberapa halaman, snapshot yang sudah kita miliki, maka kita cukup memberikan snapshot yang ada, setelah itu kita melakukan operasi untuk membuat snapshot baru di latar belakang dan menggantinya sudah tersedia. Cara melakukannya di PHP:


 exec('/usr/bin/php ' . __DIR__ . '/snapshot.php -a ' . $affiliation_type . ' -l ' . urlencode($full_uri) . ' > /dev/null 2>/dev/null &'); 

Tidak pernah melakukannya.
Di NodeJS, ini dapat dicapai dengan memanggil fungsi asinkron:


 async function saveSnapshot() { getSnapshot().then((res) => { db.saveSnapshot().then((status) => { if(status.err) console.error(err) }) }) } /** *     await * ..    resolve()   */ saveSnapshot() 

Singkatnya, Anda tidak mencoba untuk memotong sinkronisasi, tetapi Anda memutuskan kapan harus menggunakan eksekusi kode sinkron dan kapan harus menggunakan asinkron. Dan itu sangat nyaman. Terutama ketika Anda belajar tentang kemungkinan Promise.all ()


Mesin mesin pencari penerbangan itu sendiri dirancang sedemikian rupa sehingga mengirimkan permintaan ke server kedua yang mengumpulkan dan mengumpulkan data, dan kemudian beralih ke itu untuk data siap-untuk-masalah. Halaman arahan digunakan untuk menarik lalu lintas organik.


Misalnya, untuk permintaan "Flights Moscow St. Petersburg" halaman akan dikeluarkan dengan alamat / tiket / moscow / saint-petersburg / , dan membutuhkan data:


  1. Harga maskapai di arah ini untuk bulan ini
  2. Harga maskapai dalam arah ini untuk tahun yang akan datang (harga rata-rata untuk setiap bulan selama 12 bulan ke depan)
  3. Jadwalkan penerbangan ke arah ini
  4. Tujuan populer dari kota pengiriman - dari Moskow (untuk menghubungkan)
  5. Tujuan populer dari kota kedatangan adalah dari St. Petersburg (untuk menghubungkan)

Di PHP, semua permintaan ini dijalankan secara sinkron - satu demi satu. Rata-rata waktu respons API per permintaan adalah 150-200 milidetik. Kami mengalikan 200 dengan 5 dan, rata-rata, hanya satu detik untuk memenuhi permintaan ke server dengan data. NodeJS memiliki fungsi hebat yang disebut Promise.all , yang mengeksekusi semua permintaan secara paralel, tetapi menulis hasilnya satu per satu. Misalnya, kode eksekusi untuk kelima permintaan di atas akan terlihat seperti ini:


 var [montlyPrices, yearlyPrices, flightsSchedule, originPopulars, destPopulars] = await Promise.all([ getMontlyPrices(), getYearlyPrices(), getFlightSchedule(), getOriginPopulars(), getDestPopulars() ]) 

Dan kami mendapatkan semua data dalam 200-300 milidetik, mengurangi waktu pembuatan data untuk halaman dari 1-1,5 detik menjadi ~ 500 milidetik.


Kesimpulan


Beralih dari PHP ke NodeJS membantu saya menjadi lebih terbiasa dengan JavaScript asinkron, belajar cara bekerja dengan janji dan asinkron / menunggu. Setelah mesin ditulis ulang, kecepatan pemuatan halaman dioptimalkan dan berbeda secara dramatis dari hasil yang ditunjukkan oleh mesin dalam PHP. Pada artikel ini, kita juga dapat berbicara tentang bagaimana modul sederhana digunakan untuk bekerja dengan cache (Redis) dan pg-janji (PostgreSQL) di NodeJS dan untuk membandingkannya dengan Memcached dan php-pgsql, tetapi artikel ini ternyata cukup produktif. Dan karena mengetahui "bakat" saya dalam menulis, ternyata ia juga tidak terstruktur dengan baik. Tujuan artikel ini adalah untuk menarik perhatian pengembang yang masih bekerja dengan PHP dan tidak menyadari kelezatan NodeJS dan pengembangan aplikasi berbasis web di dalamnya menggunakan contoh proyek kehidupan nyata yang pernah ditulis dalam PHP, tetapi karena preferensi pemiliknya pergi ke platform lain.


Saya berharap bahwa saya dapat menyampaikan pikiran saya dan kurang lebih terstruktur untuk mengungkapkannya dalam materi ini. Setidaknya saya mencoba :)


Tulis komentar apa pun - ramah atau marah. Saya akan menjawab konstruktif apa pun.

Source: https://habr.com/ru/post/id444400/


All Articles