Вы можете себе это позволить? Бюджет веб-производительности в реальном мире

в 6:49, , рубрики: HTTP/2 Push, javascript, offline first, Polymer App Toolbox, PRPL, Service Workers, vue, WPT, бюджет производительности, Клиентская оптимизация, Проектирование и рефакторинг, Разработка веб-сайтов, разработка мобильных приложений, фреймворки

Автор — Алекс Расселл, разработчик Chrome, Blink и веб-платформы в Google

TL;DR: бюджеты производительности — существенная, но недооценённая часть успешного продукта и здоровой команды. Большинство наших партнёров не осведомлены об условиях реального мира — и в результате выбирают не те технологии. Мы установили бюджет по времени пять и менее секунд до интерактивности сайта после первой загрузки, а также две или менее секунд при последующих загрузках. При соблюдении этих нормативов мы ограничены типичным устройством из реального мира и типичной сетевой конфигурацией. Это Android-смартфон за $200 на канале 400 Кбит/с, RTT 400 мс. Это означает бюджет ~130-170 КБ ресурсов критического пути, в зависимости от их состава: чем больше JS — тем меньше объём.

За последние несколько лет мы имели удовольствие работать с десятками команд. Работа оказалась просветляющей, иногда в очень неожиданных местах. Один из самых неожиданных результатов — частые случаи «западни JavaScript».

«Нам нужен новый термин для упущенных деловых возможностей из-за современного фронтенда. Может быть, “западня JavaScript”»?

Управленцы, которые дают добро на создание прогрессивных веб-приложений (PWA), часто основным мотивом называют практически беспроблемный охват новых пользователей. В то же время разработчики осваивают инструменты, которые делают возможной достижение такой цели. Никто не хотел плохого. Тем не менее, результаты «готового» проекта PWA часто требуют недель или месяцев болезненной переделки, чтобы обеспечить минимально приемлемую производительность.

Такая переделка задерживает запуск, что, в свою очередь, задерживает сбор данных о жизнеспособности выбранной PWA-стратегии. Разработчики часто не осознают проблем, пока не становится слишком поздно. Они запускают сайты, которыми просто невозможно пользоваться, если только вы не один из богатых обладателей самых лучших смартфонов.

Установка базового уровня

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

  1. Руководители полны энтузиазма. Они используют подход «делайте то, что нужно», чтобы обеспечить и сохранить быструю работу приложения.
  2. На раннем этапе установлены бюджеты производительности.
  3. Бюджеты масштабируются в соответствии с параметрами сети и устройств на рынке.
  4. Инструменты и системы непрерывной интеграции (CI) помогают отслеживать прогресс и предотвратить регрессию.

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

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

Бюджеты устанавливают объективные границы для определения, какие изменения в кодовой базе станут шагом вперёд, а какие — шагом назад с точки зрения пользователя. Без этого вы неизбежно скатитесь в западню и начнёте притворяться, что можете позволить себе больше, чем разрешено на самом деле. Очень редко мы видели команды, которые добивались успеха без бюджета, сбора метрик RUM и использования репрезентативных пользовательских устройств.

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

Улучшения для пользователей состоят из двух этапов:

  • Пересмотр предположений и растущее понимание условий реального мира
  • Автоматическое тестирование по объективному базовому уровню

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

JS ваш самый дорогой ресурс

Один отчётливый тренд — это вера в то, что фреймворк JavaScript и одностраничная архитектура (SPA) обязательно должны использоваться для разработки прогрессивных веб-приложений. Это неправда (подробнее об этом в следующей статье), а таким сайтам обязательно понадобится больше скриптов в каждом документе (например, для router-компонентов). Мы регулярно видим сайты, загружающие более 500 КБ скриптов (сжатых). Это важно, потому что все загрузки скриптов влияют на самую главную метрику: время до появления интерактивности (Time to Interactive). Сайты с таким количеством скриптов просто недоступны значительной части пользователей; статистически, пользователи не будут ждать загрузки интерфейса так долго. Если же они дожидаются загрузки, то испытывают жуткие лаги.

Нас часто спрашивали: «Почему так важно ограничение JS в 200 КБ, у нас некоторые картинки большего размера?» Хороший вопрос! Чтобы ответить на него, важно понимать, как браузер обрабатывает ресурсы (разного типа) и концепцию критического пути. В качестве своевременного введения, рекомендую недавнее выступление Кевина Шаафа.

Загрузка JavaScript с опозданием может привести к тому, что «отрендеренные на сервере» страницы не работают так, как ожидает пользователь, что очень раздражает. Такой эффект — главная причина, почему мы стремимся гарантировать надёжную интерактивность

Представим такую страницу:

<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/styles.css">
<script src="/app.js" async></script>
</head>
<body>
<my-app>
<picture slot="hero-image">
<source srcset="img@desktop.png, img@desktop-2x.png 2x"
media="(min-width: 990px)">
<source srcset="img@tablet.png, img@tablet-2x.png 2x"
media="(min-width: 750px)">
<img srcset="img@mobile.png, img@mobile-2x.png 2x"
alt="I don't know why. It's a perfectly cromunlent word!">
</picture>
</my-app>
</body>
</html>

Браузер получает этот документ в ответ на GET-запрос к https://example.com/. Сервер отправляет её в виде потока байтов, а когда браузер встречается с каждым из упомянутых в документе подресурсов, он их запрашивает.

После завершения загрузки эта страница должна реагировать на действия пользователя — там самая «интерактивность» из параметра “Time to Interactive” (TTI). Браузеры обрабатывают действия пользователя, генерируя события DOM, которых ждёт код приложения. Обработка пользовательских действий происходит в основном потоке документа, где работает и JavaScript.

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

  • Разбор HTML
  • Разбор CSS
  • Разбор и компиляция JavaScript (иногда)
  • Некоторые задачи сбора мусора JS
  • Разбор и растрирование картинок
  • Трансформации и анимации CSS с аппаратным ускорением
  • Прокрутка основного документа (если нет активных обработчиков тач-событий)

Однако следующие операции должны идти в основном потоке:

  • Выполнение JavaScript
  • Конструирование DOM
  • Макетирование
  • Обработка ввода со стороны пользователя (в том числе прокрутка в присутствии активных обработчиков тач-событий)

Если бы документ в нашем примере не полагался на JavaScript для создания элемента <my-app>, то содержимое документа, скорее всего, стало бы интерактивным как только загрузится достаточно CSS и контента для осмысленного рендеринга.

Исполнение скрипта задерживает интерактивность несколькими способами:

  • Если скрипт исполняется дольше 50 мс, время до достижения интерактивного состояния увеличивается на всё время, необходимое для скачивания, компиляции и исполнения JS
  • Любой DOM или UI, созданный на JS, недоступен для использования, пока работает скрипт

Изображения же не блокируют основной поток, не блокируют взаимодействие во время парсинга и растрирования и не мешают другим частям UI запуститься в интерактивном режиме или сохранить её. Поэтому картинка 150 КБ не увеличит значительно TTI, а вот JS такого размера задержит интерактивность на время, необходимое для следующих задач:

  • Запрос кода, включая DNS, TCP, HTTP с накладными расходами на разархивирование
  • Разбор и компиляция функций верхнего уровня JS
  • Выполнение скрипта

Эти шаги часто повторяются.

Если выполнение скрипта укладывается в 50 мс, то TTI не увеличится, но это нереально. 150 КБ сжатого JavaScript разархивируется примерно в 1 МБ кода. Как задокументировал Эдди, весь процесс занимает более секунды на большинстве телефонов в мире, не считая времени на скачивание.

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

Глобальная правда

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

Здесь важны две цифры:

Медианный пользователь работает в медленной сети. Вопрос только в том, насколько медленное соединение.

Наши метрики в Google дают противоречивую картину (я работаю над тем, чтобы внести ясность). Некоторые системы показывают медианные RTT около 100 мс для пользователей 3G. Другие показывают, что медианный пользователь не способен передать или получить отдельный пакет менее чем за 400 мс на некоторых крупных рынках.

Полагаю, следует выбрать консервативный вариант. Соперничающие, перегруженные соты способны сделать «быстрые» сети брутально медленными, дисперсия передачи сильно уменьшает эффективность TCP, а натуральные всплески сетевого трафика работают против нас.

Для разработчиков Google работает специально созданная сеть «деградированного 3G», чтобы оценивать поведение приложений в таких условиях. Она симулирует соединение с RTT 400 мс и пропускной способностью 400-600 Кбит/с (плюс дисперсия задержки и симуляция потери пакетов). Учитывая противоречивую картину, которую показывают наши метрики, это можно принять за базовый уровень.

Однако симуляция потери пакетов и дисперсия задержки способны сильно затруднить бенчмарки и занизить результаты. Эффект потерянного пакета во время поиска DNS даёт разницу в секунды, что затрудняет сравнение результата до и после внесённых во время разработки изменений. Вероятно, наш базовый уровень должен взять более низкую пропускную способность и повышенную задержку, пожертвовав потерей пакетов. Мы теряем в точности реального мира, зато получаем повторяемость тестов, возможность сравнивать результаты до и после внесённых изменений, а также сравнивать разные продукты. Здесь ещё можно говорить и говорить о влиянии DNS, TLS, сетевой топологии и других факторов. Если хотите углубиться в эту тему, настоятельно рекомендую книгу Ильи Григорика “High Performance Browser Networking”. Одно лишь описание RRC стоит вашего потраченного времени.

Вернёмся к нашему базовому уровню. Мы примерно определились с симуляцией сети: RTT 400 мс, канал 400 Кбит/с. Что насчёт самого устройства?

На прошлогодней конференции Chrome Dev Summit я обсудил некоторые температурные и энергетические ограничения, которые создают огромную разницу в производительности между мобильным и десктопным устройством. Добавьте сюда зияющую пропасть между производительностью топовых и дешёвых устройств из-за разных характеристик чипа, таких как размер кэша. К счастью, там проще подобрать базовый уровень, чем со скоростью сети: более половины мобильных пользователей в США используют устройства под Android. Если посмотреть на другие страны, то подавляющее большинство продаваемых смартфонов сейчас (и последние пять лет) работают под Android. Средняя цена таких устройств в большинстве регионов падает благодаря повсеместной распространённости Android и неуклонному снижению цены в экосистеме. В свою очередь, это влияет на единственный главный тренд в определении глобального базового уровня бюджета производительности по оборудованию: следующий миллиард пользователей выйдет в онлайн, когда сможет себе это позволить. Это означает снижение средней цены смартфонов в обозримом будущем. А это, в свою очередь, означает, что все улучшения в количестве-транзисторов-на-доллар конвертируются в более низкую цену, а не в более быстрые устройства (в среднем).

В 2016 году истинно медианное устройство продавалось примерно за $200 без привязки к оператору. В этом году медианное устройство ещё дешевле, но примерно с такой же производительностью. Можно ожидать, что производительность медианного устройства застынет на одном уровне ещё несколько лет. Это одна из причин, почему я в прошлом году предложил в качестве базового устройства Moto G4, а в этом году рекомендую его же или Moto G5 Plus.

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

  • ~$200 (новый, без привязки к оператору) Android-смартфон
  • На медленных сетях 3G, эмуляция:
    • RTT 400 мс
    • скорость 400 Кбит/с

Для большинства разработчиков создание приложений в такой среде сродни выращиванию овощей на Марсе. К счастью, эта конфигурация доступна на webpagetest.org/easy, так что мы можем воссоздать марсианские условия здесь на Земле в любое время.

Вычисление приемлемого уровня

Последнее, что нужно обсудить в бюджете производительности — это время. Какое время будет слишком долгим?

Мне нравится определение Моники:

«The Monica Perf Test: если вы не установите зрительный контакт с незнакомцем, пока загружается приложение до первой отрисовки, то это слишком медленно»

…но оно скорее качественное, чем количественное. В цифрах, хотелось бы, чтобы каждая загрузка страницы занимала менее секунды (см. RAIL). В реальном мире такое невозможно, так что мы с партнёрами установили метрику Time-to-Interactive (TTI):

  • TTI до 5 секунд для первой загрузки
  • TTI до 2 секунд для последующих загрузок

Теперь у нас есть всё необходимое для создания примерного бюджета производительности для продукта в 2017 году.

Первая загрузка

Если вычесть время, условия сетевого соединения и основные этапы критического пути, то мы получаем несколько интересных результатов. Можно начать с бюджета для первой загрузки в 5 секунд и посчитать, какую передачу можно себе позволить.

Сначала вычтем из бюджета 1,6 секунды на поиск DNS и рукопожатие TLS, что оставляет 3,4 секунды для всего остального.

Теперь посчитаем, сколько данных можно передать по такому каналу за 3,4 секунды: 400 Кбит/с = 50 КБ/с. 50 КБ/с * 3,4 = 170 КБ.

ПРИМЕЧАНИЕ: Это обсуждение явно приведёт в ярость компетентных сетевых инженеров. В предыдущих версиях этой статьи обсуждались медленный старт, bdp, масштабирование окна tcp и тому подобное. Всё это было сложно понять. Упрощение не слишком значительно влияет на общие выводы, так что эти подробности были исключены.

Современные веб-приложения в основном состоят из JS, то есть нам также нужно вычесть время на разбор и оценку JS. Сжатие gzip для кода JS составляет от 5x до 7x, так что 170 КБ архива JS превращаются примерно в 850 КБ-1 МБ кода JS. Согласно предыдущим оценкам, для его запуска требуется около секунды (предполагая, что там нет ресурсоёмкого DOM, а он конечно же есть). Поиграв немного с этими цифрами, можно уложиться в 3,4 секунды скачивания и парсинга/оценки, ограничившись передачей JS в размере 130 КБ.

И последняя деталь: если какой-то из ресурсов критического пути загружается из другого места (например, из CDN), то нужно вычесть из бюджета ещё время соединения с ним (~1,6 с), что ещё больше сокращает долю времени из 5 секунд, которую мы можем потратить на сетевую передачу данных и работу на стороне клиента.

Подводя итоги, в идеальных условиях наш примерный бюджет для ресурсов критического пути (CSS, JS, HTML и данные) составляет:

  • 170 КБ для сайтов без особого количества JS
  • 130 КБ для сайтов, сделанных на фреймворках JS

Это даёт нам возможность рассмотреть единственный самый неотложный вопрос, который стоит в современной фронтенд-разработке: «Вы можете себе это позволить?»

Например, есть ваш JS-фреймворк отнимает ~40 КБ на сайте, перегруженном JS (у которого бюджет 130 КБ благодаря времени обработки JS), то остаётся всего лишь 90 КБ на остальное. Всё ваше приложение должно поместиться в этот объём. 100-килобайтный фреймворк, загружаемый с CDN, уже на 20 КБ превышает бюджет.

Вспомните: ваш любимый фреймворк может уложиться в 40 КБ, но что насчёт системы данных? Router-компонентов, которые вы добавили? Внезапно 130 КБ уже не кажутся таким уж большим объёмом, с учётом данных, шаблонов и стилей.

Жизнь по бюджету означает постоянно спрашивать себя: «Действительно ли я могу себе это позволить?»

Вы можете себе это позволить? Бюджет веб-производительности в реальном мире - 1

Вторая загрузка

В идеальном мире все страницы загружаются быстрее, чем за секунду, но по многим причинам часто это неосуществимо. Поэтому дадим себе чуть больше свободы и бюджет 2 секунды на вторую (третью, четвёртую и т.д.) загрузку.

Почему не пять? Потому что нам больше не нужно обращаться в сеть для загрузки UI. Service Workers и архитектуры offline first позволяют вывести интерактивные пиксели на экран, вообще не обращаясь к сети. Это ключ к стабильно высокой производительности.

Две секунды — это вечность на современных CPU, но нам по-прежнему нужно грамотно их распределить с учётом следующих факторов:

  • Время создания процесса (Android относительно медленный по сравнению с другими ОС)
  • Время для чтения байтов с диска (оно ненулевое даже на флеш-накопителях!)
  • Время выполнения кода

Все виденные мною приложения, которые укладывались в пятисекундную первую загрузку и правильно реализовали принцип offline-first, укладывались также в бюджет двух секунд, и достижение лимита в одну секунду тоже возможно! Но внедрение offline-first — огромная проблема для многих команд. Проектирование с локальным сохранением последних пользовательских данных (last-seen), надёжное и последовательное кэширование ресурсов приложения, фокусы с обновлением кода приложения с помощью жизненного цикла Service Worker могут стать большой задачей.

Я с нетерпением жду, что инструменты продолжат развиваться в этом направлении. Самый продвинутый из известных мне фреймворков сейчас — Polymer App Toolbox, так что если не уверены с чего начать, начинайте с него.

130-170 КБ… Да вы точно шутите!?!

Многие команды, с которыми нам пришлось разговаривать, задавали вопрос: а возможно ли вообще уместить что-то осмысленное в такой маленький объём 130 КБ. Возможно! Паттерн PRPL делает это с помощью агрессивного разделения кода в зависимости от маршрута, кэширования гранулированных ресурсов (последовательные страницы) с помощью Service Worker и умного использования современных расширений протоколов вроде HTTP/2 Push.

Все вместе эти инструменты позволяют уместить современный функциональный интерфейс менее чем в 100 КБ по критическому пути.

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

Несмотря на все рассуждения, можно уложиться в бюджет даже не отказываясь полностью от фреймворков. И Wego, и Ele.me созданы с помощью современных инструментов (Polymer и Vue, соответственно) — и реально работают сегодня, помогая клиентам осуществлять транзакции. Большинство приложений менее сложны, чем эти. Жить по бюджету не значит голодать.

Инструменты для команд на бюджете

Уложиться в бюджет действительно трудно, но выгоды для бизнеса и пользователей огромны. Не так часто обсуждаются выгоды для групп разработчиков и их руководителей. Ни один ведущий программист или менеджер проекта не хочет оказаться напротив руководителя, который подходит к нему с телефоном в руках и спрашивает: «А чего оно работает так медленно, когда я в отпуске?»

Это не теоретические рассуждения.

Я видел команды, которые только что закончили переделку кода на современном технологическом стеке — и они в течение часа сидели прибитыми, когда мы показывали их «лучшее» и «быстрое» приложение в условиях реального мира.

Любой потеряет лицо, если продукт не соответствует ожиданиям. Месяцы незапланированного аврала по повышению производительности отодвигают внедрение новых функций и негативно влияют на моральный дух команды. Когда производительность становится проблемой, менеджеры среднего звена стараются подавить неуверенность в себе и в то же время закрыть команду от летящего дерьма, на что сама команда и рассчитывает. Хуже того, менеджеры могут и сами начать сомневаться в команде. Кризис производительности может иметь долговременные последствия; может ли организация быть уверена, что команда выдаст качественный продукт? Могут ли они доверять ведущему программисту, который рекомендует использовать новую технологию или осуществить дополнительные крупные инвестиции? Потом взаимные обвинения. Это ужасный опыт, особенно для разработчиков, которые слишком часто находятся в позиции невероятного давления «исправь это как можно быстрее» — и «это» может быть базовой технологией, на которой основан продукт.

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

Конечно, производительность — это не (весь) продукт. Многие заторможенные или предназначенные для узкой ниши приложения великолепно себя чувствуют. Уникальный сервис, который нужен людям (и они готовы постараться, чтобы его получить), способен перевесить любые эти опасения. Некоторые преуспели даже в App Store и Play Market, где непросто привлечь аудиторию. Но продуктам на конкурентных рынках важно каждое преимущество.

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

  • webpagetest.org/easy: наш любимый инструмент для одноразового анализа
  • Скрипты WPT: для команд, которые не хотят устанавливать специальный инстанс WPT, и у них есть публичные URL приложений WIP, интеграция со скриптами WPT может стать хорошим вариантом для проведения регулярных «проверок»
  • Приватные инстансы WPT: если команда хочет интегрировать WPT напрямую в свою CI или в системы commit-queue (автоматизированная очередь тестов перед коммитом), то следует изучить вариант установки приватного сервера WPT и оборудования
  • Scripted Lighthouse: не готовы для полноценного инстанса WPT? Scripting Lighthouse поможет CI автоматизировать анализ сайта и поиск регрессий
  • grunt-perfbudget — ещё более простой инструмент автоматизированного WPT-тестирования для вашей CI. Используйте его!
  • Speedcurve и Calibre: сетевые сервисы автоматизируют регулярную проверку производительности в условиях реального мира
  • Webpack Performance Budgets: если команда использует Webpack на этапах сборки, то эта конфигурация во время разработки выдаёт очень удобные предупреждения о ресурсах, превышающих бюджет
  • bundlesize и pr-bot устанавливают бюджеты для каждого скрипта, которые будут автоматически приведены в действие в процессе пулл-реквеста. Рекомендуется!

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

Если группа начинает работать с нуля, моя настоятельная рекомендация — начать со стека, ориентированного на структуру приложения, разделение кода и целевую сборку. Лучшее на сегодняшний день:

  • Polymer App Toolbox
  • Next.js, предпочтительно с Preact в качестве более лёгкой библиотеки исполняющей системы

Какие бы инструменты ни выбрала ваша команда, самым важным является бюджет. Без него даже самые продвинутые «лёгкие» фреймворки могут запросто создать раздутые, непригодные к использованию приложения. Начинать с глобального базового уровня и увеличивать бюджет только на основании конкретных метрик — единственный известный мне способ гарантировать, что проект будет хорош для всех.

Примечания

Ради экономии времени и места придётся отложить для следующей статьи обсуждение архитектур, имеющих запас на будущее. Любопытствующие могут изучить Service Workers, Navigation Preload и Streams. Их совместная мощь должна фундаментально трансформировать оптимальное время загрузки страниц в 2018 году и далее.

И последнее. Благодарю всех, кто просмотрел первые черновики этой статьи, среди них: Винамрата Сингал, Пол Кинлэн, Питер О’Шонесси, Эдди Османи и Грэй Нортон. Надеюсь, их героические попытки избавить статью от ошибок не уступают моему таланту добавлять новые.

Автор: m1rko

Источник

Поделиться

* - обязательные к заполнению поля