Полгода назад мне посчастливилось решать интересную и нетипичную задачу по затаскиванию Symfony в Yii2 монолит. Вводные были такими:

  • Объем кодовой базы 180+ тысяч строк PHP кода.

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

  • Связанность кода была очень высокой.

  • Монолит был сделан на базе Advanced шаблона. Количество точек входа в приложение – семь, то есть, 7 файлов index.php.

  • В монолите не использовался DIC (что скорее облегчало задачу).

Из пожеланий руководства имелось следующее:

  • Весь старый код должен работать без изменений на прежних роутах.

  • Возможность дорабатывать старый код должна сохраняться.

  • Весь новый код команда будет стараться писать под управлением Symfony.

  • Все семь точек входа в приложение должны сохраниться в неизменном виде, причем для каждой точки входа должна быть возможность абсолютно прозрачно и незаметно для внешнего наблюдателя выполнять как старый код на Yii2, так и новый код на Symfony.

  • Весь старый код, обеспечивающий аутентификацию и авторизацию, должен работать, также должна быть возможность в новом коде пользоваться Symfony Security. Иными словами, нужно скрестить две секьюрити.

В данном материале я подробно и пошагово распишу весь процесс решения этой задачи с пояснениями, почему были выбраны именно такие решения и как это работает.

С чего начать?

С первого взгляда на эту задачу были очевидны несколько проблем:

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

  • В легаси-коде, работающем под Yii2, должен быть доступ к DIC Symfony для работы как с компонентами фреймворка (сериалайзер, валидатор и т. п.), так и с сервисами, которые будут написаны командой в будущем.

  • В новом Symfony коде должны исправно работать все ActiveRecord, т. к. перетащить базу из 250 таблиц на доктрину было просто нереально.

  • Каким-то образом должны слаженно работать оба роутинга: иишный и симфонийский.

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

Вкратце, там предлагается два подхода. Первый: “Front Controller with Legacy Bridge”. В рамках этого подхода предлагается вручную вызвать инициализацию симфонийского объекта Request, скормить его симфонийскому же Кернелу и, в случае получения ответа 404, скормить тот же Request некоему LegacyBridge, который должны придумать мы сами. Вот и все советы от Симфони. Лично мне в таком подходе сразу не понравилось две вещи:

  1. Если я весь такой рестовый и хочу в своем контроллере ответить 404 по собственной воле, то это будет приводить к повторной обработке моего запроса вторым фреймворком.

  2. Последовательный запуск фреймворков не помогает решить проблему доступа к ActiveRecord из Symfony приложения и, наоборот, доступа к симфонийскому DIC из Yii2 приложения.

Второй подход называется “Legacy Route Loader” и предполагает какое-то совсем простое устройство того приложения, с которого осуществляется миграция. Судя по примерам кода, предполагается назначить некоторому количеству php файлов старого приложения отдельные роуты (по одному на файл), при вызове которых эти самые файлы будут реквайриться. Не очень понятно, где вообще такое может быть применимо, но точно не в нашем случае.

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

Проектирование решения

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

Вторым источником информации нам послужит сам код, в основном классы Symfony\Component\HttpKernel\HttpKernel и yii\base\Application. Для наглядности я набросал упрощенную схемку обработки запроса обоими фреймфорками.

Не знаю как у вас, а у меня при взгляде на оба цикла, размещенные рядом, сразу появилось желание посравнивать два фреймворка в этом разрезе.) Но, поскольку цель данной статьи не в этом, я сделал это сравнение (с неожиданным для себя выводом) в отдельном посте в своем telegram канале. А мы с вами двигаемся дальше.

Обратите внимание на концептуальное сходство обоих циклов обработки запроса. В сущности, решение, которое напрашивается само собой, это запуск одного цикла внутри другого. У этих двух циклов есть лишь два общих места: это сырые данные для создания объекта Request ($_GET$_POST, $_COOKIE$_FILES$_SERVER) и поток вывода php скрипта, куда будет отправлено содержимое объекта Response (тело и заголовки).

Трудно представить себе ситуацию, в которой один из фреймворков будет менять то содержимое глобальных массивов, из которого собирается Request. То есть, в этой точке фреймворки мешать друг другу не будут. Что касается потока вывода, то тут все просто: мы перехватим Response одного из фреймворков до отправки на вывод и преобразуем его в Response другого фреймворка.

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

Такой подход позволяет решить почти все сформулированные выше проблемы:

  • Yii2 приложение будет запускаться «как обычно», ничего не зная о том, что оно запущено внутри Symfony.

  • При этом из Yii2 приложения будет доступ к DIC Symfony

  • Поскольку Yii2 предоставляет доступ ко всем своим компонентам через глобальный объект, то и внутри Symfony будет доступ к компонентам Yii2 (прежде всего к Active Record).

Единственная проблема, которую предстоит решить, это «скрещивание» роутинга двух фреймворков, и чуть позже я опишу ее решение, но сначала давайте разберем, как осуществляется загрузка двух фреймворков.

Подготовка Yii2 к загрузке внутри Symfony

Если еще раз взглянуть на схему цикла обработки запроса фреймворком Yii2 и сопоставить ее с кодом, то получается такая последовательность:

# index.php: инстанцируем и запускаем приложение
(new yii\web\Application($config))->run();

#---------------------------------------------

# yii\base\Application::run(): обрабатываем запрос, формируем ответ и отправляем его
$response = $this->handleRequest($this->getRequest());
//...
$response->send();

#---------------------------------------------

# yii\web\Response::send(): вываливаем Response на вывод php скрипта
$this->sendHeaders(); //внутри уже вызов php функции header()
$this->sendContent(); //внутри уже вызов echo

В этой схеме нас не устраивает то, что, в отличие от Symfony, Yii2 не отдает никуда наружу свой Response, а сразу же его вываливает в вывод скрипта. Да, есть событие EVENT_AFTER_REQUEST, и, судя по коду, можно в обработчике этого события установить свойство Request::isSent в true, что остановит отправку содержимого в вывод скрипта, но я решил здесь пойти другим путем и просто отнаследовался от классов yii\web\Application и yii\web\Response, переопределив методы run() и send() соответственно. В обоих методах была изменена, по сути, одна строчка:

class Application extends yii\web\Application
{
    public function run(): ?Response
    {
        try {
            //…
            $response->send();
            $this->state = self::STATE_END;
            
            return $response; //возвращаем отсюда весь Response вместо $response->exitStatus
        } catch (ExitException $e) {
            //…
        }
    }
}
class SilentResponse extends yii\web\Response
{
    public function send(): void
    {
        //…
        $this->trigger(self::EVENT_AFTER_PREPARE);

        //просто выпиливаем вызовы $this->sendHeaders() и $this->sendContent()

        $this->trigger(self::EVENT_AFTER_SEND);
        $this->isSent = true;
    }
}

Теперь наше Yii2 приложение, завершив обработку запроса, просто вернет из метода run() свой Response, который мы спокойненько можем перелить в симфонийский Response. А значит, можно реализовывать последовательную загрузку двух фреймворков.

Реализация загрузки двух фреймворков

Как в Symfony, так и в Yii2 экземпляры приложений инстанцируются в файле index.php, поэтому вначале я приведу код уже объединенного мной файла index.php, а потом объясню, как это работает.

use src\Common\Infrastructure\Symfony\Kernel;

defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
defined('YII_ENABLE_ERROR_HANDLER') or define('YII_ENABLE_ERROR_HANDLER', false);

require __DIR__ . '/../../vendor/autoload_runtime.php';

return function (array $context) {
    putenv('YII_APP_NAME=biz');

    return new Kernel($context['APP_ENV'], (bool)$context['APP_DEBUG']);
};

Как видите, от иишного файла здесь остались только объявления констант. А к стандартному коду запуска Symfony добавлена инициализация переменной окружения YII_APP_NAME, содержащей имя текущего Yii2 приложения. Это нужно для того, чтобы без плясок с бубном внутри Symfony понимать, через какой файл index.php мы вошли в приложение.

Поскольку структура папок в нашем проекте уже продиктована шаблоном Yii2, и по условиям задачи она должна остаться, то и куча файлов index.php по одному для каждого приложения в каждом окружении у нас остается. То есть, переход на Симфони не должен требовать переконфигурации веб-сервера. Подробнее об этом я расскажу в конце статьи.

Обратите внимание на отключение иишного error handler через константу YII_ENABLE_ERROR_HANDLER. Это нужно сделать, поскольку оба фреймворка вызывают функцию PHP set_error_handler() со своим обработчиком. Подробнее об этом я также расскажу в конце статьи.

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

class ApplicationLoader
{
    public function __construct(
        private readonly string $projectRoot,
        private readonly string $yiiAppName,
        private ?Application $app = null,
    ) {
    }

    public function load(): void
    {
        if (null !== $this->app) {
            return;
        }

        require $this->projectRoot . '/vendor/yiisoft/yii2/Yii.php';
        require $this->projectRoot . '/common/helpers/shortcuts.php';
        require $this->projectRoot . '/common/config/bootstrap.php';

        $localBootstrap = $this->projectRoot . '/' . $this->yiiAppName . '/config/bootstrap.php';
        if (file_exists($localBootstrap)) {
            require $localBootstrap;
        }

        $localConfigFile = $this->projectRoot . '/' . $this->yiiAppName . '/config/main-local.php';
        $localConfig = [];
        if (file_exists($localConfigFile)) {
            $localConfig = require $localConfigFile;
        }

        $config = ArrayHelper::merge(
            require $this->projectRoot . '/common/config/main.php',
            require $this->projectRoot . '/common/config/main-local.php',
            require $this->projectRoot . '/' . $this->yiiAppName . '/config/main.php',
            $localConfig
        );

        $this->app = new Application($config); //здесь наш Application из примера кода выше
    }

    public function getApp(): ?Application
    {
        if (null === $this->app) {
            $this->load();
        }

        return $this->app;
    }

    public function isLoaded(): bool
    {
        return null !== $this->app;
    }
}

Этот класс зарегистрирован в симфонийском DIC, и параметры $projectRoot и $yiiAppName (помните, мы в index.php объявляли env YII_APP_NAME?) пробрасываются в него через контейнер. Назначение класса понятно: загружать фреймворк по запросу и следить за тем, чтобы был загружен всегда только один экземпляр.

Поскольку Yii2 загружает все свои компоненты при вызове конструктора класса Application, то, стоит нам вызвать наш ApplicationLoader, как нам сразу станет доступен глобальный объект Yii со всеми его компонентами, включая уже наполненный данными Request и соединение с БД. С этого момента любой класс ActiveRecord будет работать, и мы можем доставать наши сущности из базы в любом месте симфонийского приложения (включая консоль).

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

Реализуем роутинг

Теперь, когда все готово к последовательному запуску двух фреймворков, осталось разобраться с роутингом. Именно роутинг потребовал от меня наибольшего внимания во время решения задачи по переходу с Yii2 на Symfony.

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

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

При разработке объединенного роутинга мне пришлось решать две проблемы. Первая, попроще, это возможность возвращать из симфонийского приложения ответы 404 так, чтобы это не тригерило запуск Yii2.

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

Либо ломалась обработка фреймворком исключительных ситуаций (не выводилась стандартная страница об ошибке, некорректно отрабатывало событие kernel.exception, и т. д.), либо на этапе выбора фреймворка не было контекста Security (не было понятно, какой юзер залогинен), либо ломался симфонийский роутинг, либо происходила еще какая-то дичь.

В конце концов, решение было найдено, и состояло оно в том, чтобы задекорировать симфонийский RouterListener вместо того, чтобы пытаться делать что-то до или после него. Сначала приведу код, а потом поясню его.

#[AsDecorator(decorates: 'router_listener')]
class ApplicationSwitcher extends RouterListener
{
    public function __construct(
        private readonly ApplicationLoader $applicationLoader,
        //здесь идут зависимости родительского класса, опускаю их
    ) {
        parent::__construct($matcher, $requestStack, $context, $logger, $projectDir, $debug);
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        try {
            parent::onKernelRequest($event);
        } catch (NotFoundHttpException|MethodNotAllowedHttpException $e) {
            $this->applicationLoader->transferControl($e);
        }
    }
}

В конструкторе от себя я добавил только инъекцию своего ApplicationLoader, а все остальные аргументы продиктованы родительским классом. В переопределенном методе onKernelRequest() делается очень простая вещь:

  1. Сначала вызываем родительский метод onKernelRequest(). Именно в нем происходит работа симфонийского роутинга, и определние, каким контроллером будет обработан запрос.

  2. Если контроллер не был разрезолвлен, то из родительского метода onKernelRequest() будет выброшено одно из двух исключений, которые мы отлавливаем.

  3. В обработчике этих двух исключений мы вызываем метод ApplicationLoader::transferControl(), делая тем самым две вещи: во-первых, мы устанавливаем флаг, что управление должно быть передано фреймворку Yii2, а во-вторых, мы как бы откладываем симфонийское исключение, запоминая его на будущее.

Вот код методов transferControl() и isControlTransferred() (который понадобится нам позже) класса ApplicationLoader.

public function transferControl(\Throwable $symfonyException): void
{
    $this->controlTransferred = true;
    $this->symfonyException = $symfonyException;
}

public function isControlTransferred(): bool
{
    return $this->controlTransferred;
}

У такого решения по роутингу есть очевидные плюсы:

  1. Оно легковесное, кода очень мало.

  2. Мы никак не вмешиваемся в работу симфонийского роутинга (в отличие от решения с собственными обработчиками того же kernel.request).

  3. Если в ходе работы Symfony возникнут какие-то ошибки, мы их никак не заглушаем, т. к. отлавливаем всего два исключения: на ненайденный роут и неподдерживаемый HTTP метод.

  4. Теперь из симфонийских контроллеров спокойно можно бросать любые HTTP эксцепшены, включая NotFound, и это не будет тригерить загрузку Yii2.

  5. Загрузка Yii2 выполняется отложенно и не привязана к роутингу. Здесь, в роутинге мы только делаем пометочку, что надо загрузить Yii2 в будущем.

  6. Симфонийские HTTP исключения NotFound и MethodNotAllowed мы сохраняем на будущее и, если в Yii2 также не найдется роута на обрабатываемый запрос, мы выбросим сохраненное исключение так, будто никакого Yii2 и не было, то есть, даже здесь мы не вмешиваемся в работу Symfony.

Последний компонент, который нам понадобится, это еще один обработчик kernel.request, который будет отрабатывать после роутинга (на самом деле, вообще самым последним) и, если была сделана пометка на передачу управления в Yii2, загружать этот фреймворк. Итак, вот код класса YiiApplicationRunner.

#[AsEventListener(priority: -1000)]
class YiiApplicationRunner
{
    public function __construct(
        private readonly ApplicationLoader $applicationLoader,
        private readonly Security $security,
        private readonly string $symfonyAppEnv
    ) {
    }

    public function __invoke(RequestEvent $event): void
    {
        if (!$this->applicationLoader->isControlTransferred()) {
            return;
        }

        try {
            $config = $this->security->getFirewallConfig($event->getRequest());
            $application = $this->applicationLoader->getApp();
            if (null !== $config && $config->isSecurityEnabled()) {
                $symfonyUser = $this->security->getUser();
                $usersEqual = $application->user?->getId() === $symfonyUser?->getId();
                if (null === $symfonyUser || !$usersEqual) {
                    $application->user->logout();
                }

                if (null !== $symfonyUser && !$usersEqual) {
                    $yiiUser = User::findByUsernameOrEmail($symfonyUser->getUserIdentifier());
                    $application->user->login($yiiUser);
                }
            }

            $response = $application->run();
            $response->send();
        } catch (YiiNotFoundHttpException) {
            throw $this->applicationLoader->getSymfonyException();
        } catch (\Throwable $e) {
            if ('prod' !== $this->symfonyAppEnv) {
                throw $e;
            }

            $errorHandler = $this->applicationLoader->getApp()->getErrorHandler();
            $errorHandler->silentExitOnException = true;
            $errorHandler->handleException($e);
        }

        $symfonyResponse = ResponseBridge::yiiToSymfony($this->applicationLoader->getApp()->getResponse());
        $event->setResponse($symfonyResponse);
    }
}

Давайте разберемся по порядку, что здесь происходит. Если запрос был обработан симфонийским контроллером, и метод ApplicationLoader::transferControl() не вызывался, то мы просто выходим из обработчика, продолжая обычную работу Symfony.

Если управление было передано в Yii2, то мы загружаем фреймворк вызовом ApplicationLoader::getApp() и получаем конфиг Symfony Security. Если для обрабатываемого URL существует настроенный Symfony Security Firewall, то мы вытаскиваем залогиненного в Symfony юзера.

Это нужно для объединения Security двух фреймворков. В каждом из фреймворков должен быть залогинен один и тот же юзер, или не залогинен никто. За это и отвечают несколько следующих строк кода.

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

В остальной части кода выполняется вызов иишного error handler в случае ошибки, и преобразование иишного Response в симфонийский с последующей передачей его в событие RequestEvent, откуда его уже достанет HttpKernel для отправки его содержимого на вывод php скрипта.

Вызов иишного error handler необходим для того, чтобы отрисовывалась красивая кастомная вьюшка с ошибкой. Если приложение работает не в prod окружении, то иишный error_handler не загружается, и вместо него отработает симфонийский, который выведет стандартную симфонийскую страницу с ошибкой.

По сути, основной костяк решения мы рассмотрели и одновременно с этим подошли к следующему вопросу.

Соединение Security двух фреймворков

В предыдущем разделе мы уже немного коснулись этого вопроса. В общем виде алгоритм решения такой:

  1. Перевести иишную форму логина с иишного на симфонийский эндпоинт. То есть, отправлять введенные в старой, отрисовываемой Yii2 форме, логин-пароль в новый Symfony контроллер.

  2. Реализовать симфонийский UserInterface, создав класс юзера и перенеся в него необходимый минимум полей из иишного класса User. В дальнейшем этот класс можно будет расширять, перенося по потребности все больше полей из Yii2 или придумывая новые поля.

  3. Реализовать симфонийский же UserLoaderInterface (по сути, сделать обычный репозиторий), который будет доставать из базы юзера.

  4. Написать симфонийский UserProvider, который будет использовать созданный нами репозиторий.

  5. Сделать свой PasswordHasher, который внутри себя будет дергать иишный PasswordHasher и подсунуть его в конфиг Symfony.

Итак, давайте разбираться. Первый пункт подробно расписывать не буду, так как в нем просто используется стандартный механизм аутентификации Form Login, который из коробки умеет обрабатывать запросы на аутентификацию, прилетающие от формы. Все, что здесь потребовалось от меня, это немного подправить HTML самой формы, которая рендерится иишной вьюшкой.

Что касается второго пункта, то, чтобы понять как он реализован, нужно немного погрузиться в архитектурный подход CQRS. Поскольку эта статья и так получилась немаленькой, я вынес из нее кусок, дающий базовый минимум по этой теме, в отдельный пост своего телеграм канала. Можете при желании прочитать сначала его, а потом вернуться сюда.

Чтобы логинить в Symfony иишных пользователей, нам нужно создать класс, который будет реализовывать симфонийский UserInterface и одновременно являться чем-то вроде CQRS проекции на уже существующую сущность юзера. На самом деле, такой подход я использую и в чисто симфонийских проектах, например для аутентификации по JWT. Наш новый класс (я обычно его называю SecurityUser) можно наполнять данными тремя способами:

  1. Сделать из него доктриновскую сущность и просто разметить его поля доктриновскими аннотациями.

  2. В репозитории делать прямые запросы в БД и выполнять гидрацию вручную.

  3. В репозитории обращаться к старой ActiveRecord и вручную переливать данные из старого класса User в новый.

Лично я предпочел первый способ. Вообще, я большой противник использования доктриновских аннотаций в классах сущностей, но в том-то и дело, что наш SecurityUser – это не какая не сущность, а представление залогинившегося пользователя, несущее в себе только тот срез данных, который нужен для работы Security.

Итак, с юзером и репозиторием разобрались, остались UserProvider и PasswordHasher. Поскольку эти классы очень просты, давайте я просто выложу их код без комментариев, опустив пустые или заглушенные методы.

class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
    public function __construct(
        private readonly SecurityUserRepository $userRepository
    ) {
    }

    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->userRepository->loadUserByIdentifier($identifier);
        if (null === $user) {
            throw new UserNotFoundException();
        }

        return $user;
    }
}
use yii\base\Security;

class YiiPasswordHasher implements PasswordHasherInterface
{
    private readonly Security $yiiSecurity;

    public function __construct()
    {
        $this->yiiSecurity = new Security();
    }

    public function hash(#[\SensitiveParameter] string $plainPassword): string
    {
        return $this->yiiSecurity->generatePasswordHash($plainPassword);
    }

    public function verify(string $hashedPassword, #[\SensitiveParameter] string $plainPassword): bool
    {
        return $this->yiiSecurity->validatePassword($plainPassword, $hashedPassword);
    }
}

Ну и, пожалуй, выложу я тут кусок security.yaml.

security:
    password_hashers:
        app_hasher:
            id: src\Common\Infrastructure\Symfony\Security\YiiPasswordHasher
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    providers:
        user_provider:
            id: src\Common\Infrastructure\Symfony\Security\UserProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        biz:
            request_matcher: firewall_biz_request_matcher
            lazy: true
            provider: user_provider
            form_login:
                login_path: /user/signup
                check_path: /user/login
            logout:
                path: /user/logout

Итого, что мы имеем:

  1. Аутентификация юзеров из общей таблицы БД прозрачно работает как в Symfony, так и в Yii2.

  2. Внутри Yii2 кода у нас доступен старый класс юзера, ничего не изменилось, можно дописывать при нужде какие-то правила авторизации.

  3. Внутри Symfony у нас появился свой класс юзера, который наполняется данными из старой таблицы. У нас полностью развязаны руки по реализации любой модели управления доступом на основе старых полей юзера.

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

ResponseBridge и CookieBridge

После того, как Yii2 отработал и вернул свой Response, нам необходимо перекидать его содержимое в симфонийский Response. Отдельно необходимо озадачиться переносом куки. Вот соответствующие классы:

use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use yii\web\Response as YiiResponse;

abstract class ResponseBridge
{
    public static function yiiToSymfony(YiiResponse $yiiResponse): SymfonyResponse
    {
        $symfonyResponse = new SymfonyResponse();
        $symfonyResponse->setStatusCode($yiiResponse->getStatusCode());
        $symfonyResponse->headers->add($yiiResponse->getHeaders()->toArray());
        $symfonyResponse->setContent($yiiResponse->content);

        return $symfonyResponse;
    }
}
use src\Common\Infrastructure\Yii2\Application;
use Symfony\Component\HttpFoundation\Cookie;

abstract class CookieBridge
{
    /**
     * @param Application $app
     * @return Cookie[]
     */
    public static function getYiiApplicationCookiesAsSymfonyCookies(Application $app): array
    {
        $result = [];
        $yiiCookies = $app->response->getCookies()->toArray();
        $request = $app->getRequest();
        if ($request->enableCookieValidation && !empty($request->cookieValidationKey)) {
            $validationKey = $request->cookieValidationKey;
        }

        foreach ($yiiCookies as $cookie) {
            $value = $cookie->value;
            if ($cookie->expire != 1 && isset($validationKey)) {
                $value = $app->getSecurity()->hashData(serialize([$cookie->name, $value]), $validationKey);
            }

            $result[] = new Cookie(
                $cookie->name,
                $value,
                $cookie->expire,
                $cookie->path,
                $cookie->domain,
                $cookie->secure,
                $cookie->httpOnly,
                false,
                $cookie->sameSite
            );
        }

        return $result;
    }
}

Различение в Symfony вида Yii2 Приложения

Напомню, что наше приложение построено на шаблоне Yii2 Advanced и содержит семь точек входа, то есть семь файлов index.php. Внутри Symfony нам необходимо различать, на какой именно index.php поступил запрос, чтобы корректно настраивать роутинг. Можно было бы завязаться на хост, ведь на каждый index.php смотрит свой домен или поддомен. Проблема здесь состоит в том, что в проде, деве и на различных стендах эти домены и поддомены разные.

Поэтому для различения того, на какое Yii2 приложение (на какой index.php) поступил запрос, была введена переменная окружения YII_APP_NAME, которая инициализируется отдельным значением в каждом index.php. Далее эту переменную можно пробрасывать через симфонийский DIC в любой сервис.

Для того, чтобы использовать эту переменную в роутинге, можно воспользоваться routing condition service.

#[AsRoutingConditionService(alias: 'app_kind_route_checker')]
class AppKindRouteChecker
{
    public function __construct(
        private readonly string $currentAppKind
    ) {
    }

    public function check(Request $request, string $requiredAppKind): bool
    {
        return $this->currentAppKind === $requiredAppKind;
    }
}

Приведенный выше класс параметрируется той самой переменной YII_APP_NAME и осведомлен о том, в контексте какого Yii2 приложения обрабатывается запрос (на какой index.php был сделан запрос). В своем единственном методе наш AppKindRouteChecker просто сравнивает переданный в него appKind с текущим Yii2 приложением. Дальше мы можем просто повесить атрибут #[Route] на контроллер, указав тем самым, что этот контроллер обрабатывает только те запросы, которые поступили с конкретного index.php

#[Route(condition: "service('app_kind_route_checker').check(request, 'api')")]
class ApiController extends AbstractController
{
    //…
}

В примере выше 'api' - это как раз один из семи видов (имен) Yii2 приложения. Теперь наш ApiController будет обрабатывать только те запросы, которые пришли на соответствующий index.php (на который смотрит свой поддомен).

Проброс Symfony компонентов в Yii2

Если компоненты Yii2 мы легко можем использовать как в старом, так и в новом коде, просто обратившись к глобальному объекту Yii, то для использования компонентов Symfony внутри легаси-кода нам потребуется заинжектить каждый компонент в наш класс Application, который мы наследовали от yii\web\Application.

class Application extends YiiWebApplication
{
    private ?LoggerInterface $logger = null;

    public function run(): ?Response
    {
        //…
    }

    public function initSymfonyComponents(
        LoggerInterface $logger
    ): void {
        $this->logger = $logger;
    }

    public function getLogger(): ?LoggerInterface
    {
        return $this->logger;
    }
}

Метод initSymfonyComponents() мы вызываем в ApplicationLoader в самом конце:

class ApplicationLoader
{
    //…
    public function load(): void
    {
        //…

        $this->app = new Application($config);
        $this->app->initSymfonyComponents($this->logger);
    }
}

Теперь наш логгер доступен через \Yii::$app->getLogger().

Объединение логов

Для того, чтобы Yii2 приложение начало писать в симфонийский лог вместо своего, нам понадобится написать свой Log Target.

class MonologTarget extends Target
{
    public function export(): void
    {
        $formattedMessages = array_map([$this, 'formatMessage'], $this->messages);
        foreach ($formattedMessages as $message) {
            \Yii::$app->getLogger()->log(
                $message['level'],
                $message['message'],
                $message['context']
            );
        }
    }

    public function formatMessage($log): array
    {
        [$message, $level, $category] = $log;
        $traces = self::formatTracesIfExists($log);
        $record = [
            'level' => $this->yiiLogLevelToPsr($level),
            'context' => [
                'user' => $this->getUserInfo(),
                'category' => $category,
            ],
            'message' => $this->extractMessage($message),
        ];

        if (!empty($traces)) {
            $record['context']['traces'] = $traces;
        }

        return $record;
    }

    //…
}

Полный код вываливать сюда не стал, его вы можете посмотреть в моем гите (см. ниже).

Заключение

В заключении представлю итоговую схему того, как работает все решение.

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

Это моя первая статья, надеюсь она окажется для вас полезной или, как минимум, интересной. Если что-то осталось непонятным, вы можете писать свои вопросы в комментариях, я постараюсь на них ответить и, при необходимости, дополнить статью. Спасибо за внимание.)

Комментарии (2)


  1. qeeveex
    06.02.2025 17:33

    Если скрестить ужа с ежом - получится полтора метра колючей проволоки.


  1. EnChikiben
    06.02.2025 17:33

    извините :)