Разгоняем Magento Rest API c помощью RoadRunner

Ускорение Magento c помощью RoadRunner
PHP создан умирать. И все было бы хорошо, но в последнее время это сделать ему не дают. Год назад на хабре состоялся анонс инструмента RoadRunner, заставляющего PHP процесс выйти из бесконечного круга гибели и воскрешения.

Принцип работы RoadRunner заключается в удержании запущенного процесса и подкидывания в него поступающих запросов, что позволяет, по словам разработчиков, увеличить производительность приложения (иногда даже в 40 раз).

Поскольку долгом работы я связан с Magento, то показалось отличной идеей проверить инструмент не на мифическом фреймворке, а на реальном приложении, для чего отлично подошла Magento Open Source.


Стоимость инициализации Magento приложения

Способ ускорения приложения RoadRunner предполагает уменьшение времени ответа (после прогревочного запуска) за счет сокращения накладных расходов по инициализации приложения.

Скриншот из презентации Anton Tsitou
Скриншот из презентации Anton Tsitou «Designing hybrid Go/PHP applications using RoadRunner»

В случае с Magento основные временные затраты при старте приходятся на:


  • composer autoloading
  • bootstrapping

Composer autoloading не привлекает внимание, так как стандартен для PHP приложения.

Результаты профайлинга, связанные с Composer.
Результаты профайлинга, связанные с Composer.

Bootstraping приложения Magento включает инициализацию обработчика ошибок, проверки статуса приложения, и т.д.

Самое здесь тяжелое — инициализация IoC-контейнера («ObjectManager» в терминах Magento) и рекурсивное создание экземпляров зависимостей через него для получения объекта приложения.

Результаты профайлинга, связанные с bootstraping.
Результаты профайлинга, связанные с 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 адаптер
PSR-7 адаптер

Как упоминалось выше, magento application уже возвращает ответ, так что нам надо его только сконвертировать в PSR-7 формат.

Для запроса понадобится класс, проксирующий все вызовы на текущий объект запроса, помещаемый нами в специальный регистр (да простят это извращение боги архитектуры). Кроме этого, обнаружилось, что класс запроса используется не один, а 3, так что требует зареврайтить их все через конфигурацию IoC контейнера.

Набор классов, использующихся для работы с запросами в Magento
Набор классов, использующихся для работы с запросами в 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, так что здесь они в равных условиях.


Тестовые сценарии

Для измерения производительности двух решений использовались следующие сценарии.


Сценарий 1. Создание категории
  - 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


Сценарий 2. Получение списка стран
  - name: "S2. Countries list"
    flow:
      - loop:
          - get:
              url: "/rest/V1/directory/countries"
        count: 100


Сценарий 3. Получение списка типов продуктов
  - name: "S3. Product types list"
    flow:
      - loop:
          - get:
              url: "/rest/V1/products/types"
        count: 100


Сценарий 4. Получение списка атрибут-сетов
  - name: "S4. Product attribute sets list"
    flow:
     - loop:
         - get:
             url: "/rest/V1/products/attribute-sets/sets/list?searchCriteria"
       count: 100


Сценарий 5. Получение категории
  - name: "S5. Category get"
    flow:
      - loop:
          - get:
              url: "/rest/V1/categories/2"
        count: 100


Сценарий 6. Создание продукта
  - 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


Сценарий 7. Получение списка продуктов
  - 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 накладывает определенные ограничения на подходы в разработке, однако они не противоречат устоявшимся практикам.


Напоследок приятный скриншот. Когда вы еще увидите запросы к Magento с таким временем ответа?

Шок


Ссылки


  1. Пример из статьи https://github.com/isxam/magento2-roadrunner-performance
  2. Официальный сайт RoadRunner https://roadrunner.dev/

© Habrahabr.ru