Pull to refresh

$mol_app_bench: готовим JS бенчмарки быстро и просто

Reading time 13 min
Views 3.3K

Здравствуйте, меня зовут Дмитрий Карловский и я… тот ещё гурман. Мне нравится готовить изысканные блюда, которые элегантно и просто решают привычные уже набившие оскомину проблемы. Можно долго рассказывать о преимуществах тех или иных подходов, удешевлении поддержки, ускорении разработки, упрощения отладки, но всё это остаётся достаточно субъективными оценками, над которыми нужно размышлять. Поэтому рано или поздно (но как правило преждевременно), всё обсуждение скатывается к более-менее измеримым величинам — скорости работы, скорости загрузки и прочим скоростям. И мало того, что нужно сделать несколько реализаций на разных технологиях, чтобы было что сравнивать, так ещё и не плохо было бы нарисовать интерфейс с понятной человеку выдачей результатов. А всё это — время, которого всегда не хватает, особенно, если делать хорошо.


Чтобы упростить разработку бенчмарков мы выделили общую их часть в отдельное приложение, которое рисует весь интерфейс от выбора тестируемых вариантов до наглядного представления результатов, а вариативная часть подключается извне и реализует довольно простой интерфейс. Всё "накликанное" состояние сохраняется в ссылке, так что им легко поделиться с другими гурманами. Кроме того, поддерживается локализация и сортировка результатов по разным критериям — настоящий пир для всех любителей быстрой еды.


Быстрей, а то всё вкусное съедят


Далее вы узнаете:


  • Как замутить свой бенчмарк, практически не прилагая усилий.
  • Как запилить что-то по сложнее, с загрузкой целых приложений в отдельных фреймах.
  • Как устроен $mol_app_bench изнутри.
  • Как теперь жить-то со всем этим.

Холостяцкий завтрак


Простенько и со вкусом


Допустим, мы хотим узнать сколько стоит добавление в DOM различных HTML элементов. Например, вы знали, что в Хроме элемент "Q" в 5 раз тяжелее чем "BLOCKQUOTE", а элемент "IFRAME" в 20 раз тяжелее чем "OBJECT"? Чтобы выяснить эти и другие интересные факты, мы и реализуем этот несложный бенчмарк.


Весь бенчмарк — это просто html страница, которую вы можете выложить на любом сайте. $mol_app_bench откроет её во фрейме и будет взаимодействовать с ней с помощью сообщений. Каркас страницы достаточно тривиален:


<!doctype html>
<meta charset="utf-8" />

<style>
    * {
        /* чтобы все добавляемые нами элементы накладывались друг на друга и не тратили время на позиционирование */
        position: absolute;
    }
</style>

<script>
    // сюда будем помещать все наши скрипты
</script>

Следует заранее задать сколько мы планируем выводить элементов, это число нам пригодится в дальнейшем:


var count = 500

Если бенчмарк будет открыт напрямую, а не через $mol_app_bench, то просто редиректим на $mol_app_bench с указанием открыть текущий бенчмарк:


if( window.parent === window ) document.location = '//eigenmethod.github.io/mol/app/bench/#bench=' + encodeURIComponent( location.href )

Общение бенчмарка и интерфейса происходит через простейший RPC вида [ 'имя функции' , ...[ параметры ] ]. Для простоты, будем просто вызывать соответствующую функцию с соответствующими параметрами:


window.addEventListener( 'message' , function( event ) {
    window[ event.data[0] ].apply( null , event.data.slice( 1 ) )
} )

Когда $mol_app_bench запустится, он откроет наш бенчмарк во фрейме и пришлёт нам сообщение [ 'meta' ], поэтому реализуем соответствующую функцию:


function meta() {
    done( metaData )
}

Ответ на RPC вызов $mol_app_bench ожидает в виде сообщения [ 'done' , ...[ данные ] ], так что тут тоже всё просто:


function done( result ) {
    parent.postMessage( [ 'done' , result ] , '*' )
}

Метод meta должен вернуть $mol_app_bench следующую мета-информацию о бенчмарке:


  • название бенчмарка
  • описание бенчмарка формате markdown
  • какие есть варианты реализаций
  • какие есть шаги замеров

Все тексты задаются с указанием языка. В нашем случае, мета информация будет представлена следующим образом:


var metaData = {
    title : {
        'en' : 'HTML Elements rendering time' ,
        'ru' : 'Время рендеринга HTML элементов' ,
    } ,
    descr : {
        'en' : 'Simply add **' + count + ' elements**.' ,
        'ru' : 'Просто добавляет на страницу **' + count + ' элементов** заданного типа.' ,
    } ,
    samples : {
    } ,
    steps : {
        'fill' : {
            title : {
                'en' : 'Adding elements' ,
                'ru' : 'Добавление элементов' ,
            } ,
        } ,
    } ,
}

Замер у нас тут всего один — fill, а варианты реализаций вообще не указаны, так как их нужно сгенерировать программно из списка имён элементов. Вот, кстати, и он:


var tagNames = [ 'a' , 'abbr' , 'acronym' , 'address' , 'applet' , 'area' , 'article' , 'aside' , 'audio' , 'b' , 'base' , 'basefont' , 'bdi' , 'bdo' , 'bgsound' , 'big' , 'blink' , 'blockquote' , 'body' , 'br' , 'button' , 'canvas' , 'caption' , 'center' , 'cite' , 'code' , 'col' , 'colgroup' , 'command' , 'content' , 'data' , 'datalist' , 'dd' , 'del' , 'details' , 'dfn' , 'dialog' , 'dir' , 'div' , 'dl' , 'dt' , 'element' , 'em' , 'embed' , 'fieldset' , 'figcaption' , 'figure' , 'font' , 'footer' , 'form' , 'frame' , 'frameset' , 'head' , 'header' , 'hgroup' , 'hr' , 'html' , 'i' , 'iframe' , 'image' , 'img' , 'input' , 'ins' , 'isindex' , 'kbd' , 'keygen' , 'label' , 'legend' , 'li' , 'link' , 'listing' , 'main' , 'map' , 'mark' , 'marquee' , 'menu' , 'menuitem' , 'meta' , 'meter' , 'multicol' , 'nav' , 'nobr' , 'noembed' , 'noframes' , 'noscript' , 'object' , 'ol' , 'optgroup' , 'option' , 'output' , 'p' , 'param' , 'picture' , 'plaintext' , 'pre' , 'progress' , 'q' , 'rp' , 'rt' , 'rtc' , 'ruby' , 's' , 'samp' , 'script' , 'section' , 'select' , 'shadow' , 'small' , 'source' , 'spacer' , 'span' , 'strike' , 'strong' , 'style' , 'sub' , 'summary' , 'sup' , 'table' , 'tbody' , 'td' , 'template' , 'textarea' , 'tfoot' , 'th' , 'thead' , 'time' , 'title' , 'tr' , 'track' , 'tt' , 'u' , 'ul' , 'var' , 'video' , 'wbr' , 'xmp' ]

Теперь дополним мета-информацию, конфигом вариантов реализаций:


tagNames.forEach( function( tagName ) {
    metaData.samples[ tagName ] = {
        title : { 'en' : tagName }
    }
} )

Получив мета-информацию, $mol_app_bench нарисует меню реализаций с возможностью выбора, какие из них требуется замерить. Для каждой реализации он последовательно вызовет методы, соответствующие именам шагов с передачей в качестве аргумента имя варианта реализации. В нашем случае шаг всего один:


function fill( sample ) {
    var body = document.body
    while( body.firstChild ) {
        body.removeChild( body.firstChild )

    }
    requestAnimationFrame( function() {
        var start = Date.now()
        var frag = document.createDocumentFragment()
        for( var i = 0 ; i < count ; ++ i ) {
            frag.appendChild( document.createElement( sample ) )
        }
        body.appendChild( frag )

        setImmediate( function() {
            done( Date.now() - start + ' ms' )
        } )
    } )
}

Тут мы первым делом очищаем тело документа, ждём следующего фрейма анимации, чтобы браузер закончил все свои дела, после чего создаём нужное число элементов и добавляем их все разом в DOM. Часть операций браузер выполняет асинхронно, поэтому через setImmediate мы дожидаемся, пока он всё закончит и возвращаем замеренное время.


Однако, setImmediate — сравнительно новое API, которое реализовано ещё не во всех браузерах, поэтому реализуем её простейший вариант через уже используемый нами postMessage:


var setImmediate_task
function setImmediate( task ) {
    setImmediate_task = task
    postMessage( [ 'setImmediate_task' ] , '*' )
}

Вот, собственно, и всё:



Ужин для всей семьи


Блюда на любой вкус


Допустим, мы хотим узнать какой же JS фреймворк самый быстрый. Для этого нам надо реализовать одно и то же приложение на них всех и прогнать на каждом из них одни и те же сценарии использования, замеряя время их завершения. Фреймворков в JS настолько много, что разбираться в каждом из них и реализовывать даже простейшее приложение, — потребует не одного человекомесяца. Поэтому мы возьмём уже готовые приложения из проекта ToDoMVC. Для этого мы форкнем его, добавим наш бенчмарк и выложим на github.io (Соответствующий запрос на слияние всё ещё ждёт своего звёздного часа).


Бенчмарк наш будет открывать реализации в отдельном фрейме:


<iframe id="sandbox"></iframe>

Шага замеров у нас будет 3:


  • Время открытия приложения
  • Время последовательного создания пачки задач
  • Время последовательного удаления всех задач

var metaData = {
    title : {
        'en' : 'ToDoMVC workflow benchmark' ,
        'ru' : 'ToDoMVC - производительность работы' ,
    } ,
    descr : {
        'en' : 'Sample applications is [ToDOMVC](todomvc.com) implementations. Benchmark creates **' + count + ' tasks** in sequence and then removes them.' ,
        'ru' : 'Варианты приложений являются реализациями [ToDOMVC](todomvc.com) приложения для управления списком дел. В тесте замеряется время последовательного создания **' + count + ' задач** с последующим их удалением.' ,
    } ,
    samples : {
    } ,
    steps : {
        'start' : {
            title : {
                'en' : 'Load and init' ,
                'ru' : 'Загрузка и запуск' ,
            } ,
        } ,
        'fill' : {
            title : {
                'en' : 'Tasks creating' ,
                'ru' : 'Создание задач' ,
            } ,
        } ,
        'clear' : {
            title : {
                'en' : 'Tasks removing' ,
                'ru' : 'Удаление задач' ,
            } ,
        } ,
    } ,
}

Информация о вариантах реализаций находится в файле learn.json, который мы не парясь синхронно загрузим и дополним мета-информацию:


var xhr = new XMLHttpRequest
xhr.open( 'get' , '../learn.json' , false )
xhr.send()
var learn = JSON.parse( xhr.responseText )
for( var lib in learn ) {
    if( lib === 'templates' ) continue
    learn[ lib ].examples.forEach( function( example ) {
        if( !/^examples\/[-a-zA-Z0-9_\/]+$/.test( example.url ) ) return
        metaData.samples[ example.url.replace( /(^examples\/|\/$)/g , '' ) ] = {
            title : { 'en' : learn[ lib ].name + ' ' + example.name }
        }
    } )
}

Прежде, чем браться за реализацию замеров, мы запомним ссылку на фрейм и запишем селекторы для поиска нужных элементов в приложениях:


var sandbox = document.getElementById( 'sandbox' )
var selector = {
    adder : '#new-todo,.new-todo,.todo__new,[mol_app_todomvc_add]' ,
    adderForm : '#todo-form,.todo-form,#header form' ,
    dropper : '.destroy,[mol_app_todomvc_task_row_drop]' ,
}

Старт приложения мы детектируем по появлению элемента для добавления задач:


function start( sample ) {
    var start = Date.now()
    sandbox.src = '../examples/' + sample + '/'
    sandbox.onload = function() {
        step()
        function step() {
            if( sandbox.contentDocument.querySelector( selector.adder ) ) done( Date.now() - start + ' ms' )
            else setTimeout( step , 10 )
        }
    }
}

Для добавления задачи, мы симулируем все события, что происходят в браузере, когда пользователь вводит название и нажимает "ENTER":


function fill( sample ) {
    var adder = sandbox.contentDocument.querySelector( selector.adder )
    var adderForm = sandbox.contentDocument.querySelector( selector.adderForm )

    var i = 1
    var start = Date.now()

    step()
    function step() {
        adder.value = 'Something to do ' + i
        adder.dispatchEvent( new Event( 'input' , { bubbles : true } ) )
        adder.dispatchEvent( new Event( 'change' , { bubbles : true } ) )

        var event = new Event( 'keydown' , { bubbles : true } )
        event.keyCode = 13
        event.which = 13
        event.key = 'Enter'
        adder.dispatchEvent( event )

        var event = new Event( 'keypress' , { bubbles : true } )
        event.keyCode = 13
        event.which = 13
        event.key = 'Enter'
        adder.dispatchEvent( event )

        var event = new Event( 'compositionend' , { bubbles : true } )
        event.keyCode = 13
        event.which = 13
        event.key = 'Enter'
        adder.dispatchEvent( event )

        var event = new Event( 'keyup' , { bubbles : true } )
        event.keyCode = 13
        event.which = 13
        event.key = 'Enter'
        adder.dispatchEvent( event )

        var event = new Event( 'blur' , { bubbles : true } )
        adder.dispatchEvent( event )

        if( adderForm ) {
            var event = new Event( 'submit' , { bubbles : true } )
            event.keyCode = 13
            event.which = 13
            event.key = 'Enter'
            adderForm.dispatchEvent( event )
        }

        if( ++i <= count ) setImmediate( step )
        else done( Date.now() - start + ' ms' )
    }
}

Столько всяких событий мы кидаем, так как разные фреймворки реагируют на разные события для реализации одной и той же функциональности. Набор и конфигурацию событий пришлось подбирать вручную просматривая реализации, которые косячили с бенчмарком. Сейчас большинство реализаций отрабатывают корректно, но некоторые всё ещё косячат, что видно невооружённым глазом, когда они запускаются. Пул-реквесты от сообщества, исправляющие работу бенчмарка с этими реализациями, были бы очень кстати ;-)


И последний шаг — последовательное удаление всех задач. Тут всё просто:


function clear( sample ) {
    var start = Date.now()
    step()
    function step() {
        var dropper = sandbox.contentDocument.querySelector( selector.dropper )
        if( !dropper ) return done( Date.now() - start + ' ms' )
        dropper.dispatchEvent( new Event( 'mousedown' , { bubbles : true } ) )
        dropper.dispatchEvent( new Event( 'mouseup' , { bubbles : true } ) )
        dropper.dispatchEvent( new Event( 'click' , { bubbles : true } ) )
        setImmediate( step )
    }
}

Вот и всё, получилось не сильно-то и сложнее, не правда ли?



Самая мякотка $mol_app_bench


Моя прелесть


Сперва наметим структуру нашего приложения. Оно будет состоять из 2 панелей: основной с бенчмарком и результатами; и дополнительной с меню выбора реализаций:


$mol_app_bench $mol_view
    sub /
        <= Addon_page $mol_page
        <= Main_page $mol_page

Для дополнительной зададим заголовок и собственно список реализаций, который в дальнейшем сформируем программно:


Addon_page $mol_page
    title <= addon_title @ \Samples
    body /
        <= Menu $mol_list
            rows <= menu_options /

Пусть вас не смущает, что мы зашили английский текст прямо тут. Благодаря собачке, при сборке он будет извлечён в файл с англоязычными строками.


Рядом, зададим, что каждый пункт меню будет ни чем иным, как чекбоксом, позволяющим включить или выключить замеры соответствующей реализации:


Menu_option!id $mol_check_box
    checked?val <=> menu_option_checked!id?val false
    label /
        <= menu_option_title!id \

Как можно заметить, у нас тут применяются односторонний и двусторонний биндинги для взаимодействия со вложенными компонентами. Имя справа от стрелочки — имя свойства нашего компонента (в данном случае — компонента приложения), а слева — свойство вложенного компонента.


В основной панели мы выведем информационный блок с описанием и результатами, а также песочницу, в которую будут загружаться приложения:


Main_page $mol_page
    title <= title -
    body /
        <= Inform $mol_view
        <= Sandbox $mol_view
            dom_name \iframe

Как можно заметить, заголовком этой панели будет заголовок всего приложения, который также будет выводиться и в названии вкладки браузера.


В информационном блоке выведем описание через компонент визуализации markdown, а результаты выведем через компонент вывода сравнительных таблиц:


Inform $mol_view sub /
    <= Descr $mol_text
        text <= description \
    <= Result $mol_bench
        result <= result null
        col_head_label!id /
            <= result_col_title!id / 
        col_sort?val <=> result_col_sort?val \

Последний штрих, зададим заголовок для колонки с названиями реализаций в выводимой сравнительной таблице, которым мы воспользуемся чуть позже:


result_col_title_sample @ \Sample

Объединим все эти кусочки кода и получим полное описание структуры приложения.


Теперь можно браться за поведение, чтобы добавить которое, достаточно расширить автоматически генерируемый из описания структуры, класс:


namespace $.$mol {

    export class $mol_app_bench extends $.$mol_app_bench {

        // тут будем помещать объявления новых и переопределения уже заданных свойств

    }

}

Первым делом нам нужно получить из адресной строки ссылку на бенчмарк:


@ $mol_mem()
bench() {
    return $mol_state_arg.value( this.state_key( 'bench' ) ) || 'list/'
}

Как видно, если бенчмарк не задан, то открывается бенчмарк вывода списков разными фреймворками, который лежит рядышком.


Далее, нам потребуется ссылка на песочницу, да не просто ссылка, а с уже загруженным туда бенчмарком, поэтому записываем значение свойства мы лишь дождавшись события 'load' во фрейме:


@ $mol_mem()
sandbox( next? : HTMLIFrameElement , force? : $mol_atom_force ) : HTMLIFrameElement {
    const next2 = this.Sandbox().dom_node() as HTMLIFrameElement

    next2.src = this.bench()

    next2.onload = event => {
        next2.onload = null
        this.sandbox( next2 , $mol_atom_force )
    }

    throw new $mol_atom_wait( `Loading sandbox...` )
}

Код реализации свойства sandbox получился асинхронным, но эта асинхронность изолирована от окружающего кода, так что внешний интерфейс этого свойства остаётся по прежнему синхронным благодаря магии реактивности.


Наибольшая сложность сокрыта в свойстве "результат вызова удалённой процедуры". Помимо того, что она тоже инкапсулирует в себе асинхронность, у неё есть ещё одно ограничение — единовременно может быть вызвано не более одной процедуры, так как песочница всего одна, да и запускать несколько бенчмарков одновременно — плохая примета. Поэтому, текущая исполняемая команда записывается в свойство command_current, а command_result сначала проверяет, что сейчас не исполняется другая команда, и если исполняется, то сначала дожидается её завершения.


'command_current()' : any[]

@ $mol_mem()
command_current( next? : any[] , force? : $mol_atom_force ) {
    if( this['command_current()'] ) return
    return next
}

@ $mol_mem_key()
command_result< Result >( command : any[] , next? : Result ) : Result {
    const sandbox = this.sandbox()
    sandbox.valueOf()

    if( next !== void 0 ) return next

    const current = this.command_current( command )
    if( current !== command ) throw new $mol_atom_wait( `Waiting for ${ JSON.stringify( current ) }...` )

    requestAnimationFrame( ()=> {
        sandbox.contentWindow.postMessage( command , '*' )

        window.onmessage = event => {
            if( event.data[ 0 ] !== 'done' ) return
            window.onmessage = null

            this.command_current( null , $mol_atom_force )
            this.command_result( command , event.data[ 1 ] )
        }
    } )

    throw new $mol_atom_wait( `Running ${ command }...` )
}

Знатоки многопоточности могут узнать тут примитив синхронизации "mutex", реализованный через механизм "compare and swap". И это не спроста, ведь $mol_atom по умолчанию пытается распараллелить задачи, если это возможно. Разобраться в этом коде может быть сложновато без понимания, магии реактивности, так что рекомендую почитать указанную выше статью. Но главное — вся сложность инкапсулирована в этом свойстве и дальнейшая работа будет простой и приятной. Например, получим мета-информацию из бенчмарка:


meta() {
    type meta = {
        title : { [ lang : string ] : string }
        descr : { [ lang : string ] : string }
        samples : { [ sample : string ] : {
            title : { [ lang : string ] : string }
        } }
        steps : { [ step : string ] : {
            title : { [ lang : string ] : string }
        } }
    }
    return this.command_result< meta >([ 'meta' ])
}

А теперь, получим список всех реализаций, отсортированный по их названиям:


@ $mol_mem()
samples_all( next? : string[] ) {
    return Object.keys( this.meta().samples ).sort( ( a , b )=> {
        const titleA = this.menu_option_title( a ).toLowerCase()
        const titleB = this.menu_option_title( a ).toLowerCase()
        return titleA > titleB ? 1 : titleA < titleB ? -1 : 0
    } )
}

Названия реализаций, формируются на основе текущего языка:


menu_option_title( sample : string ) {
    const title = this.meta().samples[ sample ].title
    return title[ $mol_locale.lang() ] || title[ 'en' ]
}

Название и описание бенчмарка, получаем аналогичным образом:


@ $mol_mem()
title() {
    const title = this.meta().title 
    return title[ $mol_locale.lang() ] || title[ 'en' ] || super.title()
}

@ $mol_mem()
description() {
    const descr = this.meta().descr
    return descr[ $mol_locale.lang() ] || descr[ 'en' ] || ''
}

Пришло время сформировать и список пунктов меню:


menu_options() {
    return this.samples_all().map( sample => this.Menu_option( sample ) )
}

Состояние выбранности реализации будет зависеть от списка выбранных реализаций:


@ $mol_mem_key()
menu_option_checked( sample : string , next? : boolean ) {
    if( next === void 0 ) return this.samples().indexOf( sample ) !== -1

    if( next ) this.samples( this.samples().concat( sample ) )
    else this.samples( this.samples().filter( s => s !== sample ) )

    return next
}

Как можно заметить, реализация свойства — это и геттер и сеттер одновременно. Список же выбранных реализаций мы аналогичным образом будем хранить в ссылке:


@ $mol_mem()
samples( next? : string[] ) : string[] {
    const arg = $mol_state_arg.value( this.state_key( 'sample' ) , next && next.join( '~' ) )
    return arg ? arg.split( '~' ).sort() : []
}

Прежде чем переходить к собственно замерам, выпишем все шаги, которые собираемся пройти:


@ $mol_mem()
steps( next? : string[] ) {
    return Object.keys( this.meta().steps )
}

Пройдёмся последовательно по всем шагам для каждой реализации:


@ $mol_mem_key()
result_sample( sampleId : string )  {
    const result : { [ key : string ] : any } = {
        sample : this.menu_option_title( sampleId ) ,
    }

    this.steps().forEach( step => {
        result[ step ] = this.command_result<string>([ step , sampleId ])
    } )

    return result
}

А теперь пройдёмся по всем выбранным реализациям и сформируем общий результат, который и будет передан в компонент вывода сравнительных таблиц, как было указано в описании структуры приложения:


@ $mol_mem()
result() {
    const result : { [ sample : string ] : { [ step : string ] : any } } = {}

    this.samples().forEach( sample => {
        result[ sample ] = this.result_sample( sample )
    } )

    return result
}

У компонента $mol_bench мы так же переопределяли свойство col_head_label, куда привязали свойство result_col_title, которое возвращает пустой массив. Давайте переопределим его, чтобы оно возвращало локализованный заголовок колонки:


result_col_title( col_id : string ) {
    if( col_id === 'sample' ) return [ this.result_col_title_sample() ]
    const title = this.meta().steps[ col_id ].title
    return [ title[ $mol_locale.lang() ] || title[ 'en' ] ]
}

Кроме того, мы предоставили ему в качестве свойства col_sort наше свойство result_col_sort. Чтобы оно тоже сохранялось в ссылке, переопределим его следующим образом:


@ $mol_mem()
result_col_sort( next? : string ) {
    return $mol_state_arg.value( this.state_key( 'sort' ) , next )
}

С поведением разобрались, теперь можно украсить блюдо стилями по автоматически сгенерированным BEM-атрибутам и можно подавать к столу.


Приятного аппетита


Документацию по написанию бенчмарков и ссылки на уже реализованные, вы можете найти на странице $mol_app_bench.


Несколько примеров:



В планах:


  • Добавить фильтрацию вариантов реализаций (для быстрого поиска интересующих).
  • Поддержка маленьких экранов (сейчас приложение совсем не рассчитано на маленькие экраны).
  • Вывод результатов в виде наложенных друг на друга графиков (компактный модуль построения реактивных графиков вот-вот будет готов).

Не тормозите, предлагайте свои идеи что и как можно потестить, чего не хватает описанному тут обобщённому интерфейсу, пробуйте свои рецепты и делитесь ими с сообществом.


Меня постоянно преследуют умные мысли, но я быстрее

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+3
Comments 4
Comments Comments 4

Articles