Главная » Хабрахабр » Оптимизация бэкенда при переходе на api-based архитектуру

Оптимизация бэкенда при переходе на api-based архитектуру

Привет, Хабр.

В этой статье я бы хотел подробнее рассказать о том, как мы оптимизировали наше бэкенд-приложение, чтобы оно действительно стало шустрым. На недавнем митапе в офисе Tutu я рассказывал о том, как мы в рамках редизайна superjob.ru совершали переход от монолитного приложения к api-based архитектуре с красивыми single page applications на ReactJS на фронте и шустрым PHP-приложением на бэке.

Заинтересовавшихся — прошу под кат.

Изначальный сетап

У нас было монолитное приложение на Yii1. К Yii были добавлены некоторые компоненты Symfony: например, DI, Doctrine, EventDispatcher и прочая магия. Всё это добро крутилось под PHP 7.1.
В рамках редизайна сайта мы решили разделить монолит на два приложения: одно должно было отвечать за бизнес-логику и API, а другое — исключительно за рендеринг. Поскольку объем кодовой базы Superjob опытным бойцам внушает уважение, а неопытным — страх, крамольные мысли переписать всё с нуля/на go/ещё раз с нуля были отвергнуты. В качестве хранителя бизнес-логики и провайдера API было решено использовать часть монолита на Yii, а за рендеринг должно было отвечать новое приложение на ReactJS. Общаться между собой приложения должны были посредством JSON API. Таким образом мы хотели получить следующий сетап:

Чтобы научить приложение на Yii разговаривать на JSON API, мы после долгих раздумий реализовали свое решение с исключительно оригинальным названием Mapper, о котором уже была статья на Хабре.

Выглядело это примерно так: Mapper позволял описывать преобразования моделей в сущности JSON API при помощи yaml-конфигов, которые затем компилировались в php-код.

Это позволило нам писать существенно меньше кода руками, а также гарантировало единообразие сущностей определённого типа в API.

Кроме этого Mapper взял на себя автоматизацию многих вещей, среди которых:

  • работа с транспортным слоем JSON API,
  • взаимодействие с БД (ActiveRecord, Doctrine),
  • общение с сервисами и DI,
  • проверка прав доступа,
  • валидация моделей,
  • генерация документации,
  • что-нибудь ещё, что очень лениво делать руками.

Благодаря Mapper’у мы смогли максимально оперативно внедрять новые эндпойнты, которые тут же подхватывало фронтовое приложение, разработка которого шла параллельно.

Оптимизация

Проанализировав первые результаты работы фронтового приложения, мы выяснили, что оно генерирует в среднем около 10 запросов к API на страницу, а значит, нам следовало всерьёз задуматься об оптимизации бэкенда: чем медленнее работает API, тем сильнее страдает UX приложения, особенно если фронт вынужден выполнять запросы последовательно.

Оптимизация Mapper’а

Мы решили начать с самого очевидного — с ядра нашего API.

Часто в ответе API одна и та же сущность может упоминаться несколько раз (например, несколько вакансий со связью на одну компанию, несколько резюме, принадлежащих одному человеку, и так далее), но нам совершенно ни к чему несколько раз обрабатывать одно и то же. Первым делом мы позаботились о том, чтобы Mapper не выполнял одну и ту же работу несколько раз. Казалось бы, что тут сложного? Поэтому мы научили Mapper быстро понимать, с какими моделями в рамках текущего запроса он уже работал, и отсеивать дубли. Но в более общем случае, когда ни тип, ни уникальный признак нам не гарантированы, а переданная нам структура может быть древовидной, всё становится куда сложнее. Если мы знаем, что нам могут быть переданы модели определённого типа, имеющие уникальное значение в определённом свойстве/геттере, задача решается тривиально. Однако хочется предостеречь юных падаванов, пожелавших повторить наш путь: не стоит недооценивать противника предупреждение в документации к spl_object_hash. В случае с Mapper’ом задачу поиска дубликатов мы решили при помощи комбинации spl_object_hash и дополнительных проверок, специфичных для наших моделей. Вероятность того, что хеш уничтоженного объекта будет переиспользован в том же запросе, слишком высока, поэтому полагаться на значения, возвращаемые этой функцией, стоит лишь в том случае, когда вы уверены, что ни один из объектов не будет уничтожен до того, как вы закончите вычислять хеши.

Обработка таких связей — настоящая боль, поэтому мы научили Mapper обрабатывать такие кейсы. Для отсеивания дубликатов мы предприняли и другой шаг: поскольку JSON API позволяет клиенту запрашивать иерархические связи сущностей, вполне легитимным будет, например, такой запрос связей: user.resume.user.resume.user.resume. Связи, ссылающиеся друг на друга, помечались в конфиге специальным атрибутом, и при разборе запроса Mappper выполнял нормализацию списка связей, удаляя дубли.

Первые разрешали или запрещали доступ к конкретной сущности, а вторые могли выполнять различные преобразования над атрибутами и связями сущностей. В ходе работы над Mapper’ом мы придумали два типа сервисов, которые позволяли влиять на итоговый ответ API: это правила доступа и модификаторы. По своей сути механизм формирования таких коллекций был похож на транзакции в БД: при проходе по дереву моделей простые сущности мы рендерили сразу, а сложные отправлялись в коллекции, где ждали окончания обхода дерева. Передавать в такие сервисы каждую сущность по отдельности было довольно дорогим удовольствием, поэтому мы научили Mapper объединять сущности, требующие дополнительной обработки, в коллекции, которые затем передавались в сервисы. Затем наступал момент коммита — и обработанные сервисами сущности занимали свои места в дереве ответа.

Поскольку для своей работы Mapper использует компиляцию конфигов сущностей в php-код (а на данный момент в нашем API свыше 220 сущностей), мы позаботились о том, чтобы скомпилированный код был максимально компактным:

  • повторяющиеся куски кода мы выносим в общие функции и трейты;
  • откладываем инстанцирование объектов, а если объекты уже инстанцированы, то стараемся их переиспользовать;
  • на этапе компиляции мы стараемся выполнить все проверки, не зависящие от рантайма, чтобы сократить количество действий при обработке запросов.

Оптимизация бутстрапа

После оптимизации Mapper’а мы приступили к оптимизации бутстрапа нашего приложения.

Чтобы определить, что же выполняется при каждом запросе, мы создали пустой эндпойнт, который не выполнял абсолютно никакой полезной работы, и натравили на него профайлер (мы используем связку tideways + blackfire).

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

Часть сервисов мы стали инстанцировать в зависимости от текущего контекста: например, мы не инстанцировали сервис, отвечающий за авторизацию пользователя, если в запросе не было авторизационного заголовка. Мы проанализировали сервисы, которые инстанцировал сам бутстрап, и отказались от части из них. Это расширение заменяет инстанс сервиса на легковесную пустышку, а сам сервис не инстанцируется вплоть до первого обращения к нему. Для сервисов, чьё инстанцирование занимало существенное время, мы стали использовать расширение для Symfony DI — Lazy Services.

Дело в том, что по умолчанию EventDispatcher инстанцирует всех своих слушателей, а это создаёт ощутимый оверхед, если таких слушателей много. Затем мы решили оптимизировать работу EventDispatcher. Чтобы решить эту проблему, мы написали свой CompilerPass для DI, который, исходя из особенностей нашего приложения, либо не инстанцировал слушателей вовсе, либо инстанцировал какую-то их часть.

По умолчанию DI складывает кеш рефлексии одного класса в несколько ключей (в нашем случае — файлов), что приводит к существенному увеличению операций чтения и десериализации. Наконец, чтобы ещё сильнее сократить количество подключаемых файлов, мы выполнили небольшую оптимизацию самого DI. Это, несмотря на слегка возросший размер кеша, дало нам выигрыш по времени за счёт сокращения количества операций чтения. Мы переписали класс, ответственный за сбор и кеширование рефлексии так, чтобы кеш по одному классу хранился в одном ключе.

В конечном итоге все наши оптимизации позволили нам сократить потребление cpu на 40% и немного сэкономить память.

Оптимизация эндпойнтов

Наконец, последним этапом оптимизации бэкенда стала оптимизация конкретных эндпойнтов.

Специально для таких эндпойнтов мы добавили поддержку серверного кеширования: словарный эндпойнт при помощи комбинации заголовков Cache-Control и Expires мог попросить nginx закешировать тело ответа на определённое количество времени. Поскольку единственным источником информации для фронтового приложения был наш API, у нас было некоторое количество эндпойнтов со справочниками, словарями, конфигами и прочими редкоизменяемыми данными.

Мы интегрировали наш API с debug-панелью: с каждым ответом API приходила ссылка на панель, по которой можно было перейти и посмотреть, что происходило внутри эндпойнта: Для остальных эндпойнтов мы вооружились специальным инструментарием.

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

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

Итоги

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

Мы надеемся, что наш опыт оказался кому-то полезен, и всегда рады ответить на ваши вопросы в комментариях.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

В школах Кировской области заработала Фабрика программистов

Мы запустили проект по бесплатному обучению школьников основам современной веб-разработки в стеке Node.js / React. Пока проект работает в пилотном режиме в нескольких школах Кировской области, но мы принимаем заявки на подключение из других регионов – https://coderfactory.ru. Предыстория Все началось ...

Китай подтверждает лидерство в азиатской лунной гонке

В нулевых годах в Азии началась вторая «лунная гонка». В отличие от первой, когда в 1960-х соревновались СССР и США, стран-участников оказалось больше, а вот бюджеты меньше, и общие сроки дольше. На старте было три участника — Индия, Китай, Япония. ...