Привет! Меня зовут Илья Никитин, я по-прежнему фронтенд-разработчик в Авито, работаю в кластере BuyerX. В прошлом году я писал о том, как сложно было перевести кнопки контактов на странице объявления с Twig-шаблонизатора на React. Мы переживали, получится ли перезапуск и станет ли он последним. В итоге A/B-тест мы не раскатили, но перезапуск действительно был последним, мы учли все ошибки и пошли дальше. За прошедший год мы полностью переписали страницу объявления. Рассказываю, как это было и с какими трудностями нам пришлось столкнуться.
![](https://habrastorage.org/getpro/habr/upload_files/35e/108/b60/35e108b600a011144ca6de136e4d3ac5.png)
Для чего нужно было менять стек
Причины были те же, что и в ситуации с кнопками контактов:
Релизный цикл был напрямую связан с релизом монолита, в котором жил Twig-шаблон.
Страница объявления была собрана из кусочков, написанных с использованием разных технологий: Twig, React, Native JS, Hyper (внутренняя технология для рендера React в Twig).
К этим проблемам со временем добавились другие:
Слабое покрытие тестами. В Авито Twig считается legacy-технологией, для него нет фреймворка, который позволит писать какие-либо тесты, кроме e2e.
Есть «бесхозный» и «мёртвый» код. Из-за большого объема кода и разных подходов к его написанию, были места, которые просто забыли удалить. Или казалось, что код ещё нужен, но подтвердить это никто не мог.
Два шаблона в одном.
На этом пункте остановлюсь чуть подробнее. Юнит BuyerX сконцентрирован на опыте использования Авито покупателями. Но кроме них на платформе ещё есть продавцы, для которых нужны отдельные интерфейсы. Один из них — это страница объявления, на которой они могут увидеть, как объявление выглядит для покупателей, информацию по активности с этим объявлением — количество просмотров, способы контакта. Чтобы не дублировать код, в компании когда-то решили вместо двух шаблонов использовать один и скрывать те блоки, которые не предназначены определённому типу пользователя. Из-за этого новые фичи для покупателей могли отобразиться на объявлениях продавцов. А так как покрытие тестами у нас было слабое, тестировать приходилось руками.
![Разница в интерфейсах объявления покупателя и продавца Разница в интерфейсах объявления покупателя и продавца](https://habrastorage.org/getpro/habr/upload_files/f1d/6fd/074/f1d6fd074be463e55323dbac80003366.png)
Проблем накопилось достаточно, чтобы скорее начать их решать. Я начал переписывать код сам, но один я бы не справился. Поэтому год назад я и ещё два фронтенд-инженера объединились, чтобы общими усилиями дотащить эту историю до финала.
Подготовка к переписыванию
Чтобы переписать страницу объявления, было недостаточно использовать компоненты, которые были на тот момент, и написать с нуля те, которых не хватало. У нас были «небольшие» проблемы, которые надо было решить:
найти ответственных за код;
обсудить с коллегами, как лучше вести работы;
настроить локальную среду.
После этого предстояло разобраться с проблемами посерьёзнее. Было непонятно:
как в целом построить процесс переписывания кода;
как работать с двумя шаблонами и при этом оставить другим командам возможность вносить продуктовые изменения
От путей решения этих двух проблем зависело то, как будет вестись разработка дальше: что нужно будет поддерживать здесь и сейчас, а что можно отложить на потом. Вариантов было два: «сверху вниз» и «снизу вверх» с разницей в том, в каком порядке будут интегрированы маленькие переписанные части в общую страницу объявления.
Построить процесс: вариант «сверху вниз»
В этом варианте страница объявления с самого начала рендерилась бы как React-компонент. Далее мы бы постепенно переписывали компоненты и подключали их к новой странице.
У подхода есть две особенности:
Нужно было понять, как рендерить страницу не на React, но как React-компонент. Здесь мы использовали небольшой хак в виде dangerouslySetInnerHTML. С ним контент страницы рендерится как строка с использованием Twig, которая передается в React-компонент, где рендерится с использованием этой строки.
Нужно было проработать подход к переносу компонентов. Здесь нам тоже помог бы dangerouslySetInnerHTML, так как часть компонентов работала на React, а вторая часть была Twig-шаблонами.
Если создать структуру данных, в которой из монолита передавался бы отрендеренный в виде строки компонент, либо передавать данные для готового компонента, то из таких кусочков можно будет собрать страницу.
После переписывания оставшихся компонентов с Twig на React мы получили бы целиком переписанную страницу объявления.
Плюсы подхода:
Задаёт конечную цель в виде большого React-компонента, в котором живёт объявление.
Команды смогут сразу добавлять новые компоненты в существующий шаблон.
Минус: требует много подготовки.
Построить процесс: вариант «снизу вверх»
Суть подхода — взять определённый компонент на странице и постепенно добавлять в него новые, переписанные. Так один отдельный компонент расширится до функциональности целой страницы.
Плюсы подхода:
Он проще к восприятию.
Не требует долгой подготовки.
Итеративная разработка за счёт постепенного расширения компонента.
Минус: Не получится переписать два блока страницы, которые не находятся рядом — придётся соблюдать строгий порядок.
Мы выбрали первый путь по двум причинам:
Оборачивание всей страницы в React-компонент — чётко спланированная история. Все, кто работает со страницей будут знать, для чего мы это делаем, и что это временно.
Второй путь себя уже не оправдал в переписывании кнопок контактов, когда за почти год мы их так и не раскатали.
Проблема двух шаблонов в одном
Эта проблема комплексная, так как задевает бэкенд-часть. В нашем юните мы не знаем, как быть с функциональностью страницы продавца. Ребята из этой команды не могли в ближайшее время взять в работу переписывание своей части, а нам откладывать не хотелось. В итоге мы решили разделить один шаблон на два, и здесь тоже было два варианта:
Просто разделить на два шаблона и в коде поставить условие на выбор шаблона. Этот путь проще в реализации, но при этом будет излишнее дублирование кода.
Сделать новый URL и перенести на него код страницы продавца. Это было правильнее с точки зрения разделения логики и функциональности, но требовало больших доработок: на всех платформах пришлось бы править ссылки на путь до страницы объявления продавца.
Мы выбрали первый путь, чтобы быстрее начать работу.
После решения этих проблем мы начали переписывать всю страницу объявления. Проверять все изменения решили через A/B-тест, чтобы убедиться, что мы не потеряли ничего в фичах и не сделали грубых ошибок в работе страницы.
Процесс рефакторинга: часть 1
В октябре 2021 года мы начали работу. Первым сделали рендер всей страницы как React-компонента. Это простая задача: отрендерить Twig-шаблон в строку по стандартному методу, а затем с помощью dangerouslySetInnerHTML отрендерить компонент в пакете. Отдельный npm-пакет отделил разработку от остального кода и сделал её независимой от монолита.
Конечно, без проблем не обошлось. Страница объявления важна для индексирования, поэтому должна быть отредерена на сервере. Часть компонентов рендерится на React и с помощью SSR. Когда публикуется новая версия npm-пакета, который должен работать с SSR, агент TeamCity триггерит специальную сборку, названную BaaS — Bundle as a Service. BaaS делает сборку пакета с входной точкой в пакете и кладёт её в специальное место, путь к которому возвращает в хранилище статики. В шаблоне монолита заведены специальные теги, которые во время построения страницы парсятся PHP-кодом. Этот код сначала делает запрос к сервису статики (CDN), чтобы получить ссылку на сборку SSR-пакета, а потом с этими данными делает запрос уже к самому SSR-сервису, чтобы получить строку, которая будет подставлена в шаблон.
Проблема здесь в том, что если делать рендер контента в строку, то вызов запроса на сборку SSR-пакета не происходит. Мы решили эту проблему, почти не меняя схему работы. Продуктовых изменений мы не делали, поэтому решение раскатали без A/B-тестов.
![Схема работы SSR в монолите Схема работы SSR в монолите](https://habrastorage.org/getpro/habr/upload_files/20f/6cb/9a2/20f6cb9a28671109e6d3f8d2fb210bf4.png)
Далее мы создали объект DTO. Он содержит либо строку с шаблоном, если компонент написан на Twig; либо данные для компонента, если он написан на React. Здесь проблем не возникло, поэтому по итогу у нас получилось создать страницу наподобие конструктора, которая состоит из набора маленьких компонентов и сама рендерится как React-компонент. На данном этапе мы уже отказались от рендера страницы через dangerouslySetInnerHTML, так как она может быть собрана по частям не в глобальном шаблоне монолита, а в другом месте.
К середине декабря страница объявления уже работала в тестовом окружении с нашими данными и компонентами, часть из них мы к тому моменту переписали на новый стек.
Так как приближался новогодний фиче-фриз, мы решили запустить A/B-тест с тем, что успели сделать и посмотреть на наши метрики.
Тест работал две недели и показал две проблемы:
Просели метрики перформанса — как получения контента, так и его отрисовки. У нас всё ещё были компоненты, которые последовательно рендерились как Twig-шаблоны. Тут надо было просто продолжать их переписывать.
Просели продуктовые метрики, связанные с клиентской отрисовкой страницы объявления. Здесь либо перформанс так сильно повлиял, либо мы действительно где-то потеряли метрики.
Для исключения влияния перформанса нужно было решить задачу до конца и убедиться, что метрики перформанса не сильно изменились. Нельзя было исключить и проблему с функциональностью: мы могли что-то забыть, так как карточка объявления имеет разные специфики для разных вертикалей.
Тестирование после A/B-теста не выявило новых проблем и в итоге мы не получили сильных сигналов о том, что всё идёт не так.
В целом, метрики просели не настолько критично, чтобы останавливать работы, а значит, можно смело продолжать. Но уже после Нового года.
Процесс рефакторинга: часть 1
К концу февраля мы переписали всё остальное. Теперь карточку можно было полностью собрать из React-компонентов. Мы подготовили новый A/B-тест, где весь контент страницы объявления, кроме шапки и поисковой строки рендерится как один большой React-компонент, и ушли в несколько итераций тестирования. Для него привлекли несколько QA-инженеров, которые находили проблемы и фиксировали их, а мы вносили правки. Всего на тестирование ушло около 4 недель. В итоге мы были полностью готовы к запуску A/B-теста и начали его, когда последние правки докатились до прода.
При анализе результатов мы увидели, что продуктовые метрики всё ещё краснели, как в первом эксперименте. Разбирая проблему с перформансом, мы пришли к выводу, что задержки с рендера в монолите ушли на задержки на рендер на сервере. Они связаны с тем, что мы теперь не рендерим Twig-шаблон как строку, но вместо этого рендерим большой SSR-компонент. Нам не удалось убедиться, что перформанс влияет на продуктовые метрики.
После этого были переговоры с руководителями разработки и продакт-менеджерами юнита BuyerX о том, что раскатка A/B нужна, несмотря на просадки в метриках. Иначе мы так и не улучшим стабильность карточки и её разработки. В итоге мы получили добро. Это позволило нам начать честную раскатку карточки и вынос рендеринга контента карточки из монолита в новый десктоп.
В раскатке трудностей не возникло. После неё мы удалили код A/B-теста и весь неактуальный код. К концу апреля мы полностью переписали контент страницы объявления на React и вынесли его рендер на сторону SSR-сервиса.
Переезд на новую платформу
Параллельно раскатке рендеринга начался процесс переезда в новый десктоп, чтобы убрать рендер страницы объявления из монолита. Работы было немного: перенести рендер пакета на новую платформу, стабилизировать и отладить его. Но в прошлом A/B-тесте мы просадили метрики, поэтому при внесении изменений в техническую часть нужно было понимать, насколько станет хуже или лучше. Так что переезд пришлось обернуть A/B-тест и посмотреть метрики. Основная сложность была в построении теста таким образом, чтобы можно было сравнить рендер страницы объявления в монолите и в новом десктопе. Технические ограничения не давали нам разделить, что пойдет в монолит, а что — в новый десктоп.
Мы выбрали схему, в которой все запросы попадали в монолит, а в нём шла проверка на A/B-тест. Если мы не попадали в A/B-тест, попадали в контрольную группу или на страницу объявления продавца, который всё ещё остаётся в монолите и на Twig, то рендерили страницу объявления как обычно, через монолит и его SSR. Если попадали в тестовую группу, то делали запрос в новый десктоп. Там есть специальный роут, который принимает запрос только от монолита. При его обработке происходит запрос в монолит → мы получаем данные для отрисовки страницы объявления → рисуем её на SSR-е нового десктопа → получившуюся строку возвращаем в монолит и отдаем её пользователю.
![Схема запуска A/B-теста Схема запуска A/B-теста](https://habrastorage.org/getpro/habr/upload_files/c2b/88a/42a/c2b88a42ac7c76d52ab9d701f07aa74a.png)
Такая схема схема громоздкая и имеет большое количество запросов. Из-за этого время работы контура в тестовой группе может быть больше, и это скажется на перформансе.
Но есть и плюсы:
Реализуемость схемы и «честность» результатов. Если бы мы унесли в контрольной группе рендеринг в новый десктоп, то сравнивать результаты контрольной и тестовой группы было бы неправильно, потому что изменения появились в обеих.
Уже сейчас реализуется целевая схема работы страницы объявления. За исключением того, что запрос в новый десктоп попадает с монолита и туда же возвращается ответ, сама работа рендеринга нового десктопа и получения данных соответствует целевой картине. Поэтому мы пошли по реализации именно этого пути.
При реализации возникло две проблемы:
Мы потеряли часть заголовков при пересылке данных между монолитом и новым десктопом. Проблема оказалась чисто техническая, мы провели дебаггинг запросов и всё исправили.
Необходимость провести рефакторинг поисковой строки и подключить её в карточку, чтобы она полностью рендерилась в новом десктопе. Строка привязана к поисковым страницам, поэтому просто подключить её не вышло. Но небольшой рефакторинг помог исправить проблему и теперь вся страница теперь может отрисована на React.
Тестирование и запуск A/B прошли гладко. Результаты оказались интересными: в тестовой группе перформанс на отрисовку и получение контента улучшился, несмотря на схему с большим количеством запросов. Значит, процесс серверного рендеринга на стороне нового десктопа оказался оптимальнее, чем серверный рендеринг на стороне монолита. При раскатке лишние запросы будут убраны, и перформанс станет ещё лучше.
Продуктовые метрики тоже улучшились, но ненамного. С такими результатами мы недолго ждали согласия на раскатку и быстро к ней приступили.
Так как на новом десктопе реализована целевая схема работы страницы объявления покупателя, но остаётся страница объявления продавца, которая работает на Twig, мы приняли решение: все запросы попадают в новый десктоп. Если попадаем на страницу объявления покупателя, то идём по уже известному пути с полным рендером в новом десктопе. Если понимаем, что попали на страницу продавца, то делаем запрос в монолит, из которого получаем строку с контентом страницы объявления продавца, и возвращаем эту отрендеренную страницу в монолите пользователю. Для определения, на какую страницу мы попали, делаем запрос за всеми данными страницы объявления. Так рендер страницы покупателя получается оптимальнее, чем страница продавца. Количество запросов на эти страницы разнится в 10 раз: порядка 40 000 запросов в минуту на странице покупателя против 4 000 на странице продавца.
К концу августа мы закончили все работы и раскатили страницу объявления на новом десктопе.
Итоги
На то, чтобы переписать страницу объявления и вынести её из монолита, ушло 10 месяцев. За это время мы столкнулись с проблемами и получили большой опыт. Благодаря рефакторингу удалось избавиться от legacy-технологий и вынести код из монолита, что упрощает его разработку и выкатку в продакшен. Кроме того, удалось покрыть почти все компоненты тестами, что раньше было проблематично. Теперь переписывание страницы продавца не должно занимать так много времени :)
Предыдущая статья: Выкуп подержанных смартфонов на Авито: как мы запустили пилот за три месяца