Как техотдел «Медузы» решил оптимизировать картинки — а в процессе переделал сайт, админку и подход к интерфейсу
Рассказывает технический директор «Медузы» Борис Горячев.
Наш основной продукт — сайт — постоянно обрастает новыми функциями, но из-за этого постепенно становится более тяжелым и неповоротливым. Поэтому наш технический отдел время от времени занимается тем, чтобы сделать его легче и быстрее.
Один из основных элементов, влияющих на скорость загрузки почти любого сайта, особенно сайта медиа, — это картинки. На «Медузе» картинок очень много, и это ценный для редакции способ рассказывать истории. Требования нашей фотослужбы можно сформулировать так:
- картинка должна быть загружена в CMS (мы ее называем «Монитором») максимально быстро
- картинка должна оставаться красивой и хорошо выглядеть на всех платформах
- читатель не должен ждать загрузки этой картинки
Этот текст о том, как мы шли (и идем) к выполнению этих требований. Но сначала немного бэкграунда.
Первый подход
Когда мы запускались в 2014 году, процесс работы с загруженными в «Монитор» картинками выглядел так: файл загружался в Rails-приложение, с помощью Paperclip и Imagemagick он очищался от метаданных, сжимался с выбранным качеством (для JPEG это было в районе 75) и нарезался на три размера: маленький для телефонов, побольше для планшетов и телефонов с большими экранами, и совсем большой для компьютеров. Нарезанные файлы укладывались в облачное хранилище AWS вместе с оригиналом. Отдавались (и отдаются) они не напрямую с AWS, а через наш CDN, который кэширует его на своих edge-серверах.
Тут то же самое, но без технических деталей
Файлы, загружаемые в админку «Медузы», очищались от лишних данных, сжимались с небольшой потерей качества, обрезались под разные платформы и загружались на облачный сервер. Оттуда их копии расходились по другим серверам, откуда попадали на экраны пользователей.
API, формирующее JSON для сайта и приложений, тогда кроме простых атрибутов, таких, как заголовки материалов, отдавало еще и огромный кусок HTML-кода, который вставлялся в «контентную» часть материала и обвешивался CSS-стилями. А само API тогда было единым для всех клиентов: сайта, приложений и поддерживающих сервисов, вроде RSS и поиска.
Нам сразу было понятно, что такой подход не выдержит проверки временем, но нужно было запускаться быстро, проверять огромное количество гипотез, экспериментировать, выживать. Мы осознанно — по крайней мере, мы в это верим — выбирали «костыли», а не красоту кода и решений. Время шло, росла наша аудитория и наш продукт. А вместе с этим росли аппетиты редакции. Редакции хотелось все больше приемов и «фишек» в своих материалах. В то же время, конечно, менялся и дизайн.
Техотделу приходилось колдовать над стилями картинок, методами их показа, размерами, — они менялись вместе с изменениями в дизайне и новыми элементами на сайте. Мы добавляли и поддерживали все больше странных решений.
Вот одно из них
Этот код работал вместе с JS и CSS прослойкой, которая доставлялась в приложение отдельно, в «лейауте». Сам лейаут доставлялся в приложение по отдельному запросу и жил отдельной от приложения жизнью. Его всегда было болезненно обновлять, так как по сути это был статик html-файл с плейсхолдерами, в которые нативное приложение вставляло куски данных, которые приходили из JSON-а. Дебажить поведение компонентов, из которых собирался материал, было очень неудобно.
Со временем Монитор все сильнее бесил всех, кто с ним сталкивался: от багов страдала редакция, из-за того, что починка этих багов занимала очень много времени, бесились разработчики, а ограничения придуманной архитектуры не устраивали начальство — мы не могли быстро развивать продукт.
Мы начали переделку Монитора. Основную часть CMS, отвечающую за работу над материалами, мы переписывали примерно год, регулярно отвлекаясь на баги в старой версии. Тогда в «Медузе» появился внутренний мем: «Это будет в новом Мониторе». Так мы отвечали на большинство просьб редакции, хотя, конечно, какие-то вещи делали одновременно в старой и новой CMS: плохая версия на сейчас и хорошая на потом.
Подробно про переделку «Монитора» я скоро напишу отдельный пост в нашем блоге.
Перезапуск
После перезапуска и пересборки всей «Медузы» API оставалось прежним по формату, но оно больше не формировалось самой CMS, а обрабатывалось отдельным сервисом. И мы решились наконец разделить API на несколько — под разные клиенты.
Начать решили с мобильных приложений. К тому моменту к ним уже были вопросы по дизайну, UX и скорости работы, так что мы прибрались в приложениях и сделали API таким, каким его хотели наши iOS- и Android-разработчики.
До разделения API мы показывали контентную часть материалов через WebView, поэтому мы не могли что-то показывать исключительно в приложении или исключительно на сайте — все отображалось везде. Теперь у нас появилась возможность более гибко управлять контентом: отдавать в мобильные приложения только то, что нужно, по-другому показывать тяжелые элементы (такие, как вставки роликов с ютьюба), и наконец сделать Lazy Load, позволяющий постепенно подгружать в материал тяжелые элементы — картинки и эмбеды.
Отделив приложения, мы сконцентрировали внимание на сайте. К этому моменту уже было решено, что мы не будем вносить изменения в код существующего сайта, а напишем все с нуля (да-да, мы снова ввязались в проект, который длился больше года). В это же время у нас сменился технический директор, и мне на новом посту надо было проводить аудит проектов, решать, что можно закрыть, согласовывать это с начальством. Несмотря на все трудности, команда решила доделать новый сайт. Но перед этим я решил, что нам нужен еще один проект.
Так появился UI-kit Медузы
Чтобы быть гибким при отдаче контента, он должен был иметь форму, максимально пригодную для трансформаций. Смысловые границы единиц контента — картинок, абзацев текста, заголовков — должны быть хорошо выстроены. Единица контента не должна быть слишком простой или слишком сложной. Если она сложная — скорее всего, она состоит из более чем одного смысла. Если слишком простая — скорее всего, при использовании ее придется обвешивать усложняющей логикой, и когда понадобится переиспользовать такую единицу, придется копировать ее в разных участках кода проекта.
Возьмем, к примеру, игры «Медузы». Они позволяют рассказывать огромное количество историй в игровой форме. Они могут быть сделаны специально под повестку или под запрос рекламодателя. Или игра может быть сделана на основе так называемых механик — форматов, которые используются многократно (например, это тесты).
Код этих игр не лежит в коде сайта. Каждая из них создается как отдельный микросервис, встраиваемый на сайт через iframe и общающийся с самим сайтом через postMessage. И сайту вообще все равно, что показать в том месте, где будет игра. При этом сама игра должна быть визуально неотделимой от сайта: типографика и элементы интерфейса должны быть одинаковыми.
Когда мы только начинали делать игры, мы копировали стили, кнопки и остальные вещи, и это, конечно, было быстро, и снаружи выглядело нормально, но было ужасно внутри.
Команда решила, что это надо поменять. Мы поставили на паузу переписку сайта и начали делать свой UI-kit — библиотеку, в которую вошли все повторяющиеся элементы и стили. Мы старались не упустить длинную перспективу: и сайт, и игры, и механики — все должно было начать использовать одну библиотеку.
Все проекты «Медузы», работающие в вебе, написаны на React, и UI-kit — это npm-модуль, который сейчас подключается почти ко всему, что мы разрабатываем. И разработчик, когда ему надо отрендерить что-то, пишет примерно так: render blocks.
Что это дает?
- Фронтэнд-разработчик не думает, как именно что-то отобразить.
- Все уже отсмотрено дизайн-отделом.
- Редизайн проходит максимально безболезненно: сначала он происходит в UI-kit, его отсматривают дизайнеры, тестируются крайние значения, а потом проекты поочередно обновляются на нужную версию. В итоге все компоненты, из которых состоит «Медуза», выглядят и работают единообразно.
- Дисциплинирует дизайнеров и разработчиков — появление чего-то необычного вне UI-kit (и тем более добавление элемента в кит) должно быть аргументировано.
Устройство UI-kit
Есть две группы компонентов: контентные и интерфейсные. Вторые — это очень простые React-компоненты, такие, как кнопки и иконки.
Контентные компоненты — чуть сложнее, но при этом все равно очень простые React-компоненты со стилями, которые представляют собой одну единицу контента. Например, вот так выглядит компонент параграфа:
Компонент, который «ловит» простые блоки
А так — компонент, который рендерит картинку:
API, из которого сайт берет данные, внутри каждого материала содержит массив компонентов, которые в итоге «рендерятся» через UI-kit. С играми все работает так же, просто они ходят за своими данными в свои версии API.
Ура, мы перезапустились!
Ниже — график, на котором показано среднее время загрузки страницы. Это очень неточный показатель — учитываются вообще все страницы на всех устройствах — но даже он дает представление о тренде.
На графике можно видеть, как менялась скорость сайта за все время существования «Медузы». Когда мы только запустились с очень простым сайтом в 2014 году, он работал очень быстро. Но когда мы начали добавлять новые функции, скорость загрузки упала.
А вот такой же график, но за последние два года. На нем видно, как после перезапуска сайта время загрузки страниц снизилось.
Тут наконец настало время картинок.
Картинки
Схема работы с изображениями тогда была такой. Картинка приходила через API, где у нее был адрес вида /images/attachments/…/random.jpg. Сам файл отдавался из облачного хранилища AWS через наш CDN.
Требования к новой системе мы сформулировали так:
- решение должно позволить нам быстро менять размеры и качество отдаваемых изображений
- оно не должно быть дорогим
- оно должно выдерживать большой объем трафика
Схема, к которой мы стремились, получалась такой. Бэкенд формировал бы URL, который бы забирался клиентом — браузером или приложением. В URL содержалась бы информация о том, какая картинка нужна, в каком качестве и каких размеров она должна быть.
Если картинка по такому URL уже есть на Edge-сервере, она бы сразу отдавалась клиенту. Если нет — Edge-сервер «стучался» бы в следующий сервер, который уже передавал бы запрос в сервис. Этот сервис, получив URL изображения, декодировал бы его и определял адрес оригинальной картинки и список операций над ней. После этого сервис отдавал бы трансформированную картинку, чтобы та сохранялась в CDN и отдавалась по запросу.
В «Медузе» уже есть похожие решения. Например, мы похожим образом делаем картинки для сниппетов наших материалов и игр в соцсетях — в этом сервисе мы делаем скриншоты HTML-страниц через Headless Chrome.
Новый сервис должен был уметь работать с изображениями, применять простые эффекты, быть быстрым и отказоустойчивым. Так как мы любим все писать сами, изначально планировалось написать такой сервис на языке Elixir. Но ни у кого в команде не было достаточно времени, и уж точно ни у кого не было желания погружаться в дивный мир jpg, png и gif.
Нам еще нужно было подобрать библиотеку, которая бы занималась сжатием изображений. Imagemagick, с которым у нас уже был опыт, был не самым быстрым решением, но мы хотя бы знали, как его готовить. Все остальное было новым, и проверять пришлось бы много всего.
А еще нам нужна была поддержка формата картинок webp.
Время шло, мы морально готовились к тому, чтобы погрузиться в этот проект. Но тут кто-то из наших программистов прочитал про библиотеку imgproxy, которую выложили в Open Source ребята из Evil Martians.
По описанию это было идеальное попадание: Go, Libvps, готовый Docker-образ, конфигурирование через Env. В этот же день мы развернули библиотеку на своих ноутбуках и попросили нашего DevOps тоже с ней поиграть. Его задача была в том, чтобы поднять сервис и попытаться убить его — так мы бы поняли, какие нагрузки он выдержит на наших серверах. В это время бэкенд-команда продолжала баловаться с проектом на своих компьютерах: мы писали Ruby-скрипты и осваивали доступные функции.
Когда DevOps вернулся с вердиктом, что библиотеку можно использовать в продакшене, мы собрали большое количество картинок, пропущенных через imgproxy — нас в первую очередь интересовал webp — и понесли их фотодирекции. Сотрудники техотдела не могут сами решить, подходит ли нам такое качество или нет. Фоторедакторы передали нам свои замечания, мы что-то подкрутили, посмотрели, чтобы изображения не весили много, и пошли писать бэкенд-код.
Здесь все оказалось очень просто: так как в версии API для сайта картинки уже были отделены компонентами от всего остального, мы просто расширили их JSON и добавили дополнительные адреса в разных размерах и форматах.
Обновленное сразу поехало в продакшен, так как мы ничего не изменяли, а только добавляли — ребята на фронтэнде могли разрабатывать функции все в той же версии API. Они расширили компонент картинки по функциям, добавили туда наборы изображений под разные размеры и специальный «костыль» для Safari. Мы пару раз меняли таблицу размеров и сверяли результат глазами редакции — для этого давали ей на отсмотр текущую версию сайта и дублирующую с новыми изображениями.
Когда замечания перестали поступать, мы финально задеплоились, сбросили кэш и выкатились в продакшен.
Вывод
Суть наших изменений можно обозначить так: мы сместили место принятия решения по тому, какую картинку в каком виде отдавать, в то место, где лучше учитывается контекст и проще реализуется имплементация и поддержка.
Сейчас почти все картинки, которые вы видите на «Медузе» — результат работы imgproxy, и в каждом случае у них разные размеры и иногда разное качество. Это определяется контекстом — открыт ли материал в вебе, мобильном приложении или AMP — о котором знает API-сервис, формирующий ответы.
Если у вас есть вопросы или темы, которые хотелось бы обсудить, пишите на reports@meduza.io.
Борис Горячев