Cara Kerja JS: Pohon Sintaksis Abstrak, Parsing, dan Optimalisasi


Kita semua tahu bahwa kode JavaScript untuk proyek web dapat tumbuh hingga sangat besar. Dan semakin besar kode, semakin lama browser memuatnya. Namun masalahnya di sini bukan hanya waktu pengiriman data melalui jaringan. Setelah program dimuat, masih perlu diuraikan, dikompilasi menjadi bytecode, dan akhirnya dieksekusi. Hari ini kami menyampaikan kepada Anda terjemahan bagian 14 dari seri ekosistem JavaScript. Yaitu, kita akan berbicara tentang penguraian kode JS, bagaimana pohon sintaksis abstrak dibangun, dan bagaimana seorang programmer dapat memengaruhi proses ini, mencapai peningkatan kecepatan aplikasi mereka.

gambar

Bagaimana bahasa pemrogramannya


Sebelum berbicara tentang pohon sintaksis abstrak, mari kita memikirkan bagaimana bahasa pemrograman bekerja. Terlepas dari bahasa yang Anda gunakan, Anda selalu harus menggunakan program tertentu yang mengambil kode sumber dan mengubahnya menjadi sesuatu yang berisi perintah khusus untuk mesin. Penerjemah atau kompiler bertindak sebagai program tersebut. Tidak masalah apakah Anda menulis dalam bahasa yang ditafsirkan (JavaScript, Python, Ruby), atau dikompilasi (C #, Java, Rust), kode Anda, yang merupakan teks biasa, akan selalu melalui tahap parsing, yaitu, mengubah teks biasa menjadi struktur data disebut Pohon Sintaksis Abstrak (AST).

Pohon sintaksis abstrak tidak hanya menyediakan representasi terstruktur dari kode sumber, mereka juga memainkan peran penting dalam analisis semantik, di mana kompiler memverifikasi kebenaran konstruk perangkat lunak dan penggunaan elemen-elemennya secara benar. Setelah membentuk AST dan melakukan pemeriksaan, struktur ini digunakan untuk menghasilkan bytecode atau kode mesin.

Menggunakan pohon sintaksis abstrak


Pohon sintaksis abstrak digunakan tidak hanya dalam interpreter dan kompiler. Mereka, di dunia komputer, berguna di banyak bidang lain. Salah satu aplikasi yang paling umum adalah analisis kode statis. Analisis statis tidak mengeksekusi kode yang diberikan kepada mereka. Namun, meskipun demikian, mereka perlu memahami struktur program.

Misalkan Anda ingin mengembangkan alat yang menemukan struktur yang sering terjadi dalam kode Anda. Laporan alat semacam itu akan membantu dalam refactoring dan akan mengurangi duplikasi kode. Ini dapat dilakukan dengan menggunakan perbandingan string yang biasa, tetapi pendekatan ini akan sangat primitif, kemampuannya akan terbatas. Bahkan, jika Anda ingin membuat alat serupa, Anda tidak perlu menulis parser Anda sendiri untuk JavaScript. Ada banyak implementasi open source dari program-program semacam itu yang sepenuhnya kompatibel dengan spesifikasi ECMAScript. Misalnya - Esprima dan Acorn. Ada juga alat yang dapat membantu dalam bekerja dengan apa yang dihasilkan parser, yaitu, dalam bekerja dengan pohon sintaksis abstrak.

Pohon sintaksis abstrak, di samping itu, banyak digunakan dalam pengembangan transpiler. Misalkan Anda memutuskan untuk mengembangkan transpiler yang mengubah kode Python ke kode JavaScript. Proyek serupa dapat didasarkan pada gagasan bahwa transpiler digunakan untuk membuat pohon sintaksis abstrak berdasarkan kode Python, yang, pada gilirannya, dikonversi ke kode JavaScript. Mungkin di sini Anda akan bertanya-tanya bagaimana ini mungkin. Masalahnya adalah bahwa pohon sintaksis abstrak hanyalah cara alternatif untuk mewakili kode dalam beberapa bahasa pemrograman. Sebelum kode dikonversi ke AST, itu terlihat seperti teks biasa, ketika ditulis yang mengikuti aturan tertentu yang membentuk bahasa. Setelah parsing, kode ini berubah menjadi struktur pohon yang berisi informasi yang sama dengan kode sumber program. Akibatnya, dimungkinkan untuk melakukan tidak hanya transisi dari kode sumber ke AST, tetapi juga transformasi terbalik, mengubah pohon sintaksis abstrak menjadi representasi teks dari kode program.

Memilah JavaScript


Mari kita bicara tentang bagaimana pohon sintaksis abstrak dibangun. Sebagai contoh, pertimbangkan fungsi JavaScript sederhana:

function foo(x) {    if (x > 10) {        var a = 2;        return a * x;    }    return x + 10; } 

Parser akan membuat pohon sintaksis abstrak, yang secara skematis direpresentasikan dalam gambar berikut.


Pohon sintaksis abstrak

Harap dicatat bahwa ini adalah representasi hasil parser yang disederhanakan. Pohon sintaksis abstrak yang sebenarnya terlihat jauh lebih rumit. Dalam hal ini, tujuan utama kami adalah untuk mendapatkan ide tentang apa, di tempat pertama, kode sumber berubah sebelum dieksekusi. Jika Anda tertarik untuk melihat seperti apa struktur sintaksis abstrak nyata, gunakan situs web AST Explorer . Untuk menghasilkan AST untuk fragmen JS-code tertentu, cukup menempatkannya di bidang yang sesuai pada halaman.

Mungkin di sini Anda akan memiliki pertanyaan tentang mengapa programmer perlu tahu cara kerja parser JS. Pada akhirnya, parsing dan mengeksekusi kode adalah tugas browser. Di satu sisi, Anda benar. Gambar di bawah ini menunjukkan waktu yang diperlukan untuk beberapa proyek web terkenal untuk melakukan berbagai langkah dalam proses mengeksekusi kode JS.

Lihatlah gambar ini lebih dekat, mungkin Anda akan melihat sesuatu yang menarik di sana.


Waktu yang dihabiskan untuk mengeksekusi kode JS

Lihat? Jika tidak, lihat lagi. Sebenarnya, kita berbicara tentang fakta bahwa, rata-rata, browser menghabiskan 15-20% dari waktu mem-parsing kode JS. Dan ini bukan data bersyarat. Berikut adalah informasi statistik tentang pekerjaan proyek web nyata yang menggunakan JavaScript dengan satu atau lain cara. Mungkin angka 15% mungkin tidak terlalu besar bagi Anda, tapi percayalah, ini banyak. Aplikasi satu halaman biasanya memuat sekitar 0,4 MB kode JavaScript, dan browser membutuhkan sekitar 370 ms untuk menguraikan kode ini. Sekali lagi, Anda dapat mengatakan bahwa tidak ada yang perlu dikhawatirkan. Dan ya, itu saja tidak banyak. Namun, jangan lupa bahwa ini hanya waktu yang diperlukan untuk mengurai kode dan mengubahnya menjadi AST. Ini tidak termasuk waktu yang diperlukan untuk mengeksekusi kode, atau waktu yang diperlukan untuk menyelesaikan tugas-tugas lain yang menyertai pemuatan halaman, misalnya, tugas-tugas pemrosesan HTML dan CSS dan rendering halaman . Selain itu, kami hanya berbicara tentang browser desktop. Dalam kasus sistem seluler masih lebih buruk. Secara khusus, waktu penguraian untuk kode yang sama pada perangkat seluler dapat 2-5 kali lebih lama daripada di desktop. Lihatlah gambar berikut.


Parsing waktu 1 MB JS-code pada berbagai perangkat

Inilah waktu yang diperlukan untuk mengurai 1 MB kode JS pada berbagai perangkat seluler dan desktop.

Selain itu, aplikasi web terus menjadi semakin kompleks, dan semakin banyak tugas yang ditransfer ke sisi klien. Semua ini bertujuan untuk meningkatkan pengalaman pengguna bekerja dengan situs web, untuk membawa perasaan ini lebih dekat dengan perasaan yang dialami pengguna saat berinteraksi dengan aplikasi tradisional. Sangat mudah untuk mengetahui seberapa besar ini mempengaruhi proyek web. Untuk melakukan ini, cukup buka alat pengembang di browser, buka beberapa situs modern dan lihat berapa banyak waktu yang dihabiskan untuk mem-parsing kode, kompilasi, dan segala sesuatu yang terjadi di browser saat menyiapkan halaman untuk bekerja.


Analisis situs web menggunakan alat pengembang di browser

Sayangnya, peramban seluler tidak memiliki alat tersebut. Namun, ini tidak berarti bahwa versi situs seluler tidak dapat dianalisis. Di sini alat seperti DeviceTiming akan membantu kami. Dengan DeviceTiming, Anda dapat mengukur waktu yang diperlukan untuk mem-parsing dan mengeksekusi skrip di lingkungan yang dikelola. Ini berfungsi berkat penempatan skrip lokal di lingkungan yang dibentuk oleh kode tambahan, yang mengarah pada fakta bahwa setiap kali halaman dimuat dari berbagai perangkat, kami memiliki kesempatan untuk mengukur waktu penguraian dan eksekusi kode secara lokal.

Optimasi parsing dan mesin JS


Mesin JS melakukan banyak hal berguna untuk menghindari pekerjaan yang tidak perlu dan mengoptimalkan proses pemrosesan kode. Berikut ini beberapa contohnya.

Mesin V8 mendukung skrip streaming dan caching kode. Dalam hal ini, streaming berarti bahwa sistem mem-parsing skrip yang memuat secara tidak sinkron dan skrip yang tertunda di utas terpisah, mulai melakukan ini sejak kode mulai memuat. Ini mengarah pada fakta bahwa parsing berakhir hampir bersamaan dengan selesainya memuat skrip, yang memberikan sekitar 10% pengurangan waktu yang diperlukan untuk menyiapkan halaman untuk bekerja.

Kode JavaScript biasanya dikompilasi menjadi bytecode setiap kali halaman dikunjungi. Namun bytecode ini hilang setelah pengguna menavigasi ke halaman lain. Ini disebabkan oleh fakta bahwa kode yang dikompilasi sangat tergantung pada keadaan dan konteks sistem pada waktu kompilasi. Untuk memperbaiki situasi, Chrome 42 memperkenalkan dukungan untuk caching bytecode. Berkat inovasi ini, kode yang dikompilasi disimpan secara lokal, sebagai akibatnya, ketika pengguna kembali ke halaman yang telah dikunjungi, tidak perlu mengunduh, mengurai dan menyusun skrip untuk mempersiapkannya untuk bekerja. Ini menghemat Chrome sekitar 40% dari waktu penguraian dan kompilasi. Selain itu, dalam hal perangkat seluler, ini mengarah pada penghematan daya baterai.

Mesin Carakan , yang digunakan dalam browser Opera dan telah diganti dengan V8 untuk waktu yang lama, dapat menggunakan kembali hasil kompilasi dari skrip yang sudah diproses. Tidak perlu skrip ini dihubungkan ke halaman yang sama atau bahkan diambil dari domain yang sama. Teknik caching ini, sebenarnya, sangat efektif dan memungkinkan Anda untuk sepenuhnya meninggalkan langkah kompilasi. Dia mengandalkan skenario perilaku pengguna biasa, pada bagaimana orang bekerja dengan sumber daya web. Yaitu, ketika pengguna mengikuti urutan tindakan tertentu, saat bekerja dengan aplikasi web, kode yang sama dimuat.

Penerjemah SpiderMonkey yang digunakan oleh FireFox tidak menembolok segala sesuatu dalam satu baris. Ini mendukung sistem pemantauan yang menghitung jumlah panggilan ke skrip tertentu. Berdasarkan indikator-indikator ini, bagian dari kode yang perlu optimasi ditentukan, yaitu mereka yang memiliki beban maksimum.

Tentu saja, beberapa pengembang browser mungkin memutuskan bahwa produk mereka tidak perlu di-cache sama sekali. Jadi, Masei Stachovyak , pengembang terkemuka peramban Safari, mengatakan bahwa Safari tidak terlibat dalam caching yang disusun oleh bytecode. Kemungkinan caching dipertimbangkan, tetapi belum diimplementasikan, karena pembuatan kode membutuhkan kurang dari 2% dari total waktu pelaksanaan program.

Optimalisasi ini tidak secara langsung mempengaruhi penguraian kode sumber di JS. Dalam perjalanan aplikasi mereka, segala kemungkinan dilakukan untuk, dalam kasus-kasus tertentu, sepenuhnya lewati langkah ini. Tidak peduli seberapa cepat penguraian, masih membutuhkan waktu, dan ketiadaan penguraian mungkin merupakan contoh optimalisasi sempurna.

Kurangi waktu persiapan aplikasi web


Seperti yang kami temukan di atas, akan lebih baik untuk meminimalkan kebutuhan untuk mem-parsing skrip, tetapi Anda tidak dapat sepenuhnya menghilangkannya, jadi mari kita bicara tentang bagaimana mengurangi waktu yang diperlukan untuk menyiapkan aplikasi web untuk bekerja. Bahkan, banyak yang bisa dilakukan untuk ini. Misalnya, Anda dapat meminimalkan jumlah kode JS yang termasuk dalam aplikasi. Kode kecil yang menyiapkan halaman untuk pekerjaan dapat diurai lebih cepat, dan kemungkinan besar akan membutuhkan waktu lebih sedikit untuk dieksekusi daripada kode yang lebih produktif.

Untuk mengurangi jumlah kode, Anda dapat mengatur pemuatan pada halaman hanya apa yang benar-benar dibutuhkan, dan bukan sepotong besar kode, yang mencakup segala sesuatu yang benar-benar diperlukan untuk proyek web secara keseluruhan. Jadi, misalnya, pola PRPL mempromosikan pendekatan semacam itu untuk memuat kode. Sebagai alternatif, Anda dapat memeriksa dependensi dan melihat apakah ada sesuatu yang berlebihan di dalamnya, sehingga hanya mengarah pada pertumbuhan basis kode yang tidak dapat dibenarkan. Bahkan, di sini kami menyinggung topik besar yang layak untuk materi terpisah. Kembali ke penguraian.

Jadi, tujuan dari bahan ini adalah untuk membahas teknik yang memungkinkan pengembang web untuk membantu parser melakukan tugasnya dengan lebih cepat. Teknik seperti itu ada. Parser JS modern menggunakan algoritma heuristik untuk menentukan apakah akan diperlukan untuk mengeksekusi sepotong kode sesegera mungkin, atau jika perlu dieksekusi nanti. Berdasarkan prediksi ini, pengurai sepenuhnya menganalisis fragmen kode menggunakan algoritma penguraian yang bersemangat atau menggunakan algoritma penguraian malas. Dengan analisis lengkap, Anda memahami fungsi yang harus dikompilasi sesegera mungkin. Selama proses ini, tiga tugas utama diselesaikan: membangun AST, membuat hierarki area visibilitas, dan menemukan kesalahan sintaksis. Analisis malas, di sisi lain, hanya digunakan untuk fungsi yang belum perlu dikompilasi. Ini tidak membuat AST dan tidak mencari kesalahan. Dengan pendekatan ini, hanya hierarki area visibilitas yang dibuat, yang menghemat sekitar separuh waktu dibandingkan dengan fungsi pemrosesan yang perlu dijalankan sesegera mungkin.

Padahal, konsepnya bukan hal baru. Bahkan browser yang sudah ketinggalan zaman seperti IE9 mendukung pendekatan optimasi seperti itu, walaupun, tentu saja, sistem modern telah melangkah jauh ke depan.

Mari kita periksa contoh yang menggambarkan operasi mekanisme ini. Misalkan kita memiliki kode JS berikut:

 function foo() {   function bar(x) {       return x + 10;   }   function baz(x, y) {       return x + y;   }   console.log(baz(100, 200)); } 

Seperti pada contoh sebelumnya, kode jatuh ke parser, yang melakukan parsing dan membentuk AST. Akibatnya, parser mewakili kode yang terdiri dari bagian-bagian utama berikut (kami tidak akan memperhatikan fungsi foo ):

  • Mendeklarasikan fungsi bar yang membutuhkan satu argumen ( x ). Fungsi ini memiliki satu perintah pengembalian, mengembalikan hasil dari penambahan x dan 10.
  • Mendeklarasikan fungsi baz yang membutuhkan dua argumen ( x dan y ). Dia juga memiliki satu perintah pengembalian, dia mengembalikan hasil penambahan x dan y .
  • Melakukan panggilan ke fungsi baz dengan dua argumen - 100 dan 200.
  • Melakukan panggilan ke fungsi console.log dengan satu argumen, yang merupakan nilai yang dikembalikan oleh fungsi yang sebelumnya disebut.

Ini tampilannya.


Hasil penguraian kode sampel tanpa menerapkan optimasi

Mari kita bicarakan apa yang terjadi di sini. Pengurai melihat deklarasi fungsi bar , deklarasi fungsi baz , panggilan ke fungsi baz , dan panggilan ke fungsi console.log . Jelas, parsing potongan kode ini, parser akan menghadapi tugas yang pelaksanaannya tidak akan mempengaruhi hasil program ini. Ini tentang menganalisis bar fungsi. Mengapa analisis fungsi ini tidak praktis? Masalahnya adalah bahwa fungsi bar , setidaknya dalam fragmen kode yang disajikan, tidak pernah dipanggil. Contoh sederhana ini mungkin tampak tidak masuk akal, tetapi banyak aplikasi nyata memiliki sejumlah besar fungsi yang tidak pernah dipanggil.

Dalam situasi seperti itu, alih-alih mem-parsing fungsi bar , kita bisa mencatat bahwa itu dinyatakan, tetapi tidak digunakan di mana pun. Pada saat yang sama, penguraian sebenarnya dari fungsi ini dilakukan ketika menjadi perlu, tepat sebelum pelaksanaannya. Tentu saja, ketika melakukan parsing malas, Anda perlu mendeteksi tubuh fungsi dan membuat catatan deklarasi, tetapi di sinilah pekerjaan berakhir. Untuk fungsi seperti itu, tidak perlu membentuk pohon sintaksis abstrak, karena sistem tidak memiliki informasi bahwa fungsi ini direncanakan untuk dilakukan. Selain itu, memori tumpukan tidak dialokasikan, yang biasanya membutuhkan sumber daya sistem yang cukup besar. Singkatnya, penolakan untuk mem-parsing fungsi yang tidak perlu mengarah pada peningkatan kinerja kode yang signifikan.

Akibatnya, dalam contoh sebelumnya, pengurai nyata akan membentuk struktur yang menyerupai skema berikut.


Hasil parsing kode contoh dengan optimasi

Perhatikan bahwa parser membuat catatan tentang deklarasi bar fungsi, tetapi tidak berurusan dengan analisis lebih lanjut. Sistem tidak berusaha menganalisis kode fungsi. Dalam hal ini, badan fungsi adalah perintah untuk mengembalikan hasil perhitungan sederhana. Namun, di sebagian besar aplikasi dunia nyata, kode fungsi bisa lebih lama dan lebih kompleks, berisi banyak perintah pengembalian, kondisi, loop, perintah deklarasi variabel, dan fungsi bersarang. Mem-parsing semua ini, asalkan fungsi-fungsi seperti itu tidak pernah dipanggil, adalah buang-buang waktu.

Tidak ada yang rumit dalam konsep yang dijelaskan di atas, tetapi implementasi praktisnya bukanlah tugas yang mudah. Di sini kami memeriksa contoh yang sangat sederhana, dan, pada kenyataannya, ketika memutuskan apakah suatu kode tertentu akan diminati dalam suatu program, perlu untuk menganalisis fungsi, dan loop, dan operator kondisional, dan objek. Secara umum, kita dapat mengatakan bahwa parser perlu memproses dan menganalisis sepenuhnya semua yang ada dalam program.

Di sini, misalnya, adalah pola yang sangat umum untuk menerapkan modul dalam JavaScript:

 var myModule = (function() {   //      //    })(); 

Kebanyakan parser JS modern mengenali pola ini, bagi mereka itu adalah sinyal bahwa kode yang terletak di dalam modul perlu dianalisis secara penuh.

Tetapi bagaimana jika parser selalu menggunakan parsing malas? Sayangnya, ini bukan ide yang baik. Faktanya adalah bahwa dengan pendekatan ini, jika beberapa kode perlu dieksekusi sesegera mungkin, kita akan menghadapi perlambatan dalam sistem. Parser akan melakukan satu pass parsing malas, setelah itu ia akan segera mulai sepenuhnya menganalisis apa yang perlu dilakukan sesegera mungkin. Ini akan menyebabkan sekitar 50% perlambatan dibandingkan dengan pendekatan ketika parser segera mulai sepenuhnya mem-parsing kode yang paling penting.

Optimasi kode, dengan mempertimbangkan fitur analisisnya


Sekarang kami telah menemukan sedikit tentang apa yang terjadi di dalam parser, sekarang saatnya untuk memikirkan apa yang dapat dilakukan untuk membantu mereka. Kita dapat menulis kode sehingga fungsi parsing dilakukan pada saat yang kita butuhkan. Ada satu pola yang dipahami sebagian besar parser. Ini dinyatakan dalam fakta bahwa fungsi terlampir dalam tanda kurung. Desain seperti itu hampir selalu memberi tahu parser bahwa fungsi perlu segera dibongkar. Jika parser mendeteksi braket pembuka, segera setelah itu deklarasi fungsi berikut, itu akan segera mulai parsing fungsi. Kami dapat membantu parser dengan menerapkan teknik ini saat menjelaskan fungsi yang perlu dilakukan sesegera mungkin.

Misalkan kita memiliki fungsi foo :

 function foo(x) {   return x * 10; } 

Karena tidak ada indikasi eksplisit dalam fragmen kode ini bahwa fungsi ini dijadwalkan untuk segera dieksekusi, browser hanya akan melakukan parsing malasnya. Namun, kami yakin bahwa kami akan membutuhkan fungsi ini segera, sehingga kami dapat menggunakan trik berikutnya.

Pertama, simpan fungsi dalam variabel:

 var foo = function foo(x) {   return x * 10; }; 

Harap perhatikan bahwa kami meninggalkan nama fungsi awal antara kata kunci function dan braket pembuka. Tidak dapat dikatakan bahwa ini benar-benar diperlukan, tetapi disarankan untuk melakukan hal itu, karena jika pengecualian dilemparkan ketika fungsi sedang berjalan, Anda dapat melihat nama fungsi dalam data jejak tumpukan, bukan <anonymous> .

Setelah perubahan di atas, parser akan terus menggunakan parsing malas. Untuk mengubah ini, satu detail kecil sudah cukup. Fungsi harus dilampirkan dalam tanda kurung:

 var foo = (function foo(x) {   return x * 10; }); 

Sekarang, ketika parser menemukan braket pembuka di depan kata kunci function , ia akan segera mulai menguraikan fungsi ini.

Mungkin tidak mudah untuk melakukan optimasi seperti itu secara manual, karena untuk ini Anda perlu tahu dalam kasus mana parser akan melakukan parsing malas, dan di mana penuh. Selain itu, untuk melakukan ini, Anda perlu meluangkan waktu untuk memutuskan apakah fungsi tertentu harus siap untuk bekerja secepat mungkin atau tidak.

Pemrogram, tentu saja, tidak akan mau memikul semua pekerjaan tambahan ini. Selain itu, yang tidak kalah penting dari semua yang telah dikatakan, kode yang diproses dengan cara ini akan lebih sulit dibaca dan dipahami. Dalam situasi ini, paket perangkat lunak khusus seperti Optimize.js siap membantu kami. Tujuan utama mereka adalah untuk mengoptimalkan waktu boot awal untuk kode sumber JS. Mereka melakukan analisis kode statis dan memodifikasinya sehingga fungsi-fungsi yang perlu dijalankan sesegera mungkin terlampir dalam tanda kurung, yang mengarah pada fakta bahwa browser segera mem-parsing mereka dan menyiapkannya untuk dieksekusi.

Jadi, misalkan kita memprogram, tanpa benar-benar memikirkan apa pun, dan kita memiliki fragmen kode berikut:

 (function() {   console.log('Hello, World!'); })(); 

Ini terlihat cukup normal, berfungsi seperti yang diharapkan, dieksekusi dengan cepat, karena parser menemukan braket pembuka di depan function kata kunci. Sejauh ini bagus. , , , :

 !function(){console.log('Hello, World!')}(); 

, , . , - .

, , . , , , . , , , . , , . Optimize.js. Optimize.js, :

 !(function(){console.log('Hello, World!')})(); 

, . , . , , , — .


, JS- — , . ? , , , , . , , , , JS- , . , , , -, . - . , , . , , , , . , JS- , , V8 , , . .


, -:

  • . .
  • , .
  • , , , JS-. , , .
  • DeviceTiming , .
  • Optimize.js , , .

Ringkasan


, , SessionStack , , -, . , . — . , — , -, , , .

Pembaca yang budiman! - JavaScript-?

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


All Articles