Разгоняем Magento Rest API c помощью RoadRunner
PHP создан умирать. И все было бы хорошо, но в последнее время это сделать ему не дают. Год назад на хабре состоялся анонс инструмента RoadRunner, заставляющего PHP процесс выйти из бесконечного круга гибели и воскрешения.
Принцип работы RoadRunner заключается в удержании запущенного процесса и подкидывания в него поступающих запросов, что позволяет, по словам разработчиков, увеличить производительность приложения (иногда даже в 40 раз).
Поскольку долгом работы я связан с Magento, то показалось отличной идеей проверить инструмент не на мифическом фреймворке, а на реальном приложении, для чего отлично подошла Magento Open Source.
Стоимость инициализации Magento приложения
Способ ускорения приложения RoadRunner предполагает уменьшение времени ответа (после прогревочного запуска) за счет сокращения накладных расходов по инициализации приложения.
Скриншот из презентации Anton Tsitou «Designing hybrid Go/PHP applications using RoadRunner»
В случае с Magento основные временные затраты при старте приходятся на:
- composer autoloading
- bootstrapping
Composer autoloading не привлекает внимание, так как стандартен для PHP приложения.
Результаты профайлинга, связанные с Composer.
Bootstraping приложения Magento включает инициализацию обработчика ошибок, проверки статуса приложения, и т.д.
Самое здесь тяжелое — инициализация IoC-контейнера («ObjectManager» в терминах Magento) и рекурсивное создание экземпляров зависимостей через него для получения объекта приложения.
Результаты профайлинга, связанные с bootstraping.
Внедрение RoadRunner
Для запуска RoadRunner требуется создать worker, который будет содержать цикл принятия входящих запросов и отсылку ответов. Причем инструмент работает с запросами и ответами, имплементирующими PSR-7. Из официальной документации это выглядит примерно так:
while ($req = $psr7->acceptRequest()) {
$resp = new \Zend\Diactoros\Response();
$resp->getBody()->write("hello world");
$psr7->respond($resp);
}
Magento и PSR-7
Magento пока не внедрила PSR-7 и из коробки использует свои имплементации запросов и ответов, подходы в работе которых в основном перетянуты из предыдущей версии.
Для внедрения RoadRunner нужно найти точку входа, которая бы принимала запрос в каком-то виде и возвращала ответ (пример Symfony).
В Magento существует такая точка, \Magento\Framework\AppInterface, только одна проблема, этот интерфейс не рассчитан на то, чтобы принять запрос. Но постойте, откуда он тогда попадает в приложение? Здесь стоит вернуться к началу и мантре — PHP рожден умирать. Соответственно громадная часть библиотек, пакетов, фреймворков, при проектировании и разделении на слои просто не предполагают, что запрос, оказывается, бывает не один глобальный.
По такому же принципу построен транспортный слой Magento. Хотя в документации и расписаны отличия injectable/newable объектов, на деле мы имеем использование запроса как глобального statefull сервиса, инициализирующего самого себя из глобальных переменных ($_GET, $_POST). В дополнение ко всему этому, инжектирование этого сервиса можно увидеть на всех уровнях приложения в самом ядре, что уж говорить про качество сторонних модулей.
Исходя из вышеизложенного, надежда внедрения RoadRunner только через конвертацию запросов из PSR-7-style в Magento-style была потрачена.
Внедрение PSR-7 адаптера
Формулируем задачу, приняв во внимание полученную информацию.
Хотелось бы иметь некий интерфейс приложения, принимающего PSR-7 запрос и возвращающий PSR-7 ответ. Также необходимо создать имплементацию созданного интерфейса, адаптирующую данный формат взаимодействия к Magento приложению.
PSR-7 адаптер
Как упоминалось выше, magento application уже возвращает ответ, так что нам надо его только сконвертировать в PSR-7 формат.
Для запроса понадобится класс, проксирующий все вызовы на текущий объект запроса, помещаемый нами в специальный регистр (да простят это извращение боги архитектуры). Кроме этого, обнаружилось, что класс запроса используется не один, а 3, так что требует зареврайтить их все через конфигурацию IoC контейнера.
Набор классов, использующихся для работы с запросами в Magento
Проблемы неумирающего PHP приложения
Приложение, запущенное посредством RoadRunner, имеет те же проблемы, что и любой долгоживущий php процесс, они описаны в документации (https://roadrunner.dev/docs/usage-production)
Основные проблемы уровня приложения, за которыми следует следить разработчику это:
- утечки памяти (https://www.php.net/manual/ru/features.gc.php)
- соединения с базами данных (внезапно) могут отвалиться по таймауту, как и любые другие
- грамотное управление состоянием
Особое внимание в контексте Magento следует уделить управлению состоянием, поскольку как в ядре так и в сторонних модулях, кеширование текущего пользователя/продукта/категории внутри сервиса это очень часто встречающийся подход.
protected function getCustomer(): ?CustomerInterface
{
if (!$this->customer) {
if ($this->customerSession->isLoggedIn()) {
$this->customer = $this->customerRepository->getById($this->customerSession->getCustomerId());
} else {
return null;
}
}
return $this->customer;
}
Пример метода из ядра, использующего состояние объекта.
Запуск Magento Rest API сервера через RoadRunner
Учитывая потенциальные проблемы с глобальным состоянием, основанные на опыте разработки фронтенд части Magento, для запуска была выбрана наиболее подходящая и безболезненная WebApi часть.
Первое, что надо сделать, это создать наш воркер, который будет запускаться через RoadRunner и жить бесконечно (почти). Для этого берем кусок кода из гайдов RoadRunner и добавляем туда наше приложение, обернутое PSR-7 адаптером.
$relay = new StreamRelay(STDIN, STDOUT);
$psr7 = new PSR7Client(new Worker($relay));
$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, []);
/** @var \Magento\Framework\App\Http $app */
$app = $bootstrap->createApplication(\Magento\Framework\App\Http::class);
/** @var ApplicationInterface $psr7Application */
$psr7Application = $bootstrap->getObjectManager()->create(
\Isxam\M2RoadRunner\Application\MagentoAppWrapper::class,
[
'magentoApp' => $app
]
);
while ($request = $psr7->acceptRequest()) {
try {
$response = $psr7Application->handle($request);
$psr7->respond($response);
} catch (\Throwable $e) {
$psr7->getWorker()->error((string)$e);
}
}
Код до цикла while будет выполнен при старте воркера, все что внутри цикла — при каждом новом запросе.
Когда наш воркер готов, приступаем к конфигурации RoadRunner сервера, написанного на Go. Здесь все приятно и быстро, только композер-пакет, который скачает исполняемый файл, ничего устанавливать не надо, что не может быть не приятно. Создаем конфигурацию своего сервера — самый простой выглядит примерно так.
http:
address: 0.0.0.0:8086
workers:
command: "php worker.php"
pool:
numWorkers: 1
Конфигурация RoadRunner.
В документации присутствует обилие настроек, позволяющих гибко сконфигурировать сервер, чтобы желание компилировать свой бинарник точно не наступило.
./rr serve -v -d
Запуск сервера
Тестирование решения
Инструменты
Для удобного тестирования берем что-нибудь простое, например artillery.io.
Тестировать производительность будем с помощью одного пользователя, выполняющего запросы последовательно (выполнение запросов в несколько потоков RoadRunner также поддерживает, тестирование этого вопроса оставим другим исследователям)
На входе у нас конфиг-файл artillery c двумя окружениями — Apache и RoadRunner. Они оба работают с одним и тем же инстансом Magento, так что здесь они в равных условиях.
Тестовые сценарии
Для измерения производительности двух решений использовались следующие сценарии.
- name: "S1. Create category"
flow:
- loop:
- post:
url: "/rest/V1/categories"
json:
category:
name: "name-{{prefix}}-{{ $loopCount }}"
parent_id: 2
is_active: true
count: 100
- name: "S2. Countries list"
flow:
- loop:
- get:
url: "/rest/V1/directory/countries"
count: 100
- name: "S3. Product types list"
flow:
- loop:
- get:
url: "/rest/V1/products/types"
count: 100
- name: "S4. Product attribute sets list"
flow:
- loop:
- get:
url: "/rest/V1/products/attribute-sets/sets/list?searchCriteria"
count: 100
- name: "S5. Category get"
flow:
- loop:
- get:
url: "/rest/V1/categories/2"
count: 100
- name: "S6. Create product"
flow:
- loop:
- post:
url: '/rest/V1/products'
json:
product:
sku: "sku-{{prefix}}-{{ $loopCount }}"
name: "name-{{prefix}}-{{ $loopCount }}"
attribute_set_id: 4
price: 100
type_id: "simple"
count: 100
- name: "S7. Get product list"
flow:
- loop:
- get:
url: "/rest/V1/products?searchCriteria[pageSize]=20"
count: 100
Результат
После запуска всех сценарием поочередно через RoadRunner и Apache, был получены медианы длительности выполнения запроса. По медианам видно, что скорость работы всех сценариев отличается на примерно одинаковую величину ~50ms.
Результат тестирования производительности.
Итог
Практический эксперимент подтвердил предположения о константности выигрыша в производительности RoadRunner на конкретном приложении. Использование данного инструмента позволяет ускорить обработку запросов к приложению на константу, равную времени инициализации окружения и зависимостей.
На легких обработчиках это позволяет ускорить приложение в разы, на тяжелых почти не дает ощутимого эффекта. Если ваш код медленный, то, скорее всего, подорожники ему не помогут.
Если ваше приложение написано хорошо, то, скорее всего, проблем с его работой через RoadRunner не будет, если же приложение требует адаптации для использования с RoadRunner, то, вероятнее всего, оно и без RoadRunner требовало бы того же, для более четкого соблюдения слоев архитектуры и следования стандартам разработки в области.
Magento Open Source в целом пригодна для запуска в приведенном окружении, однако требует доработок транспортного слоя и исправления бизнес-логики для предотвращения некорректного поведения при повторяющихся запросах в рамках одного процесса. Также использование RoadRunner накладывает определенные ограничения на подходы в разработке, однако они не противоречат устоявшимся практикам.
Ссылки
- Пример из статьи https://github.com/isxam/magento2-roadrunner-performance
- Официальный сайт RoadRunner https://roadrunner.dev/