Миграция существующего приложения в Symfony

Дата обновления перевода 2024-07-31

Миграция существующего приложения в Symfony

Если у вас есть существующее приложение, которое было создано не в Symfony, вам может захотеться переместить части приложения, не переписывая существующую логику с нуля. Для таких случаев существует паттерн под названием Strangler Fig Application. Основная идея этого паттерная - создание новго приложения, которое постепенно берет на себя функциональность существующего приложения. Этот подход к миграции можно реализовать с Symfony различными способами, и он имеет несколько преимуществ над переписыванием кода, вроде возможности представить новый функции в существующем приложении и уменьшение рисков, путем избегания "взрывного" релиза нового приложения.

Screencast

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

Предварительные условия

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

Note

Следующие шаги не требуют, чтобы у вас было новое приложение Symfony, и на самом деле может быть безопаснее внедрять эти изменения в вашем существующем приложении заранее.

Выбор целевой версии Symfony

Самое важное, что вам нужно сделать - решить, на какую версию вы хотите мигрировать - на текущий стабильный релиз, или на долгосрочно поддерживаемую версию (LTS). Основное различие в том, как часто вам нужно будет проводить обновления для использование поддерживаемой версии. В контексте миграции, другие факторы, вроде поддерживаемой PHP-версии или поддержи библиотек/пакетов, которые вы используете, могут также иметь сильное влияние. Использование самого нового стабильного релиза скорее всего предоставит вам больше функций, но также будет требовать более частых обновлений, чтобы гарантировать вам поддержку исправлений багов и патчей безопасности, и вам нужно будет быстрее работать над исправлением устаревших функций, чтобы иметь возможность обновляться.

Tip

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

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

Tip

Если ваш текущий проект работает на более старой версии PHP вроде PHP 5.x, обновление на более новую версию даст вам ускорение производительности, без изменения кода.

Настройка Composer

Вам также нужно будет следить за конфликтами между зависимостями в обоих приложениях. Это особенно важно, если ваше существующее приложение уже использует компоненты Symfony или библиотеки, часто используемые в приложениях Symfony, вроде Doctrine ORM, Swiftmailer или Twig. Хорошим способом гарантировать совместимость будет использовать одинаковый composer.json для зависимостей обоих проектов.

Как только вы представите компоновщик для управления зависимостями вашего проекта, вы сможете использовать его автозагрузчик, чтобы гарантировать, что вы не столкнетесь ни с какими конфликтами в связи с пользовательской автоматической загрузкой из вашего существующего фреймворка. Это обычно влечет за собой добавление раздела autoload в ваш composer.json, его конфигурацию, основываясь на вашем приложении, и замену вашей пользовательской логики на что-то вроде этого:

1
require __DIR__.'/vendor/autoload.php';

Удаление глобального состояния из наследуемого приложения

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

Настройка окружения

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

Создание страховки для регрессий

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

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

Вместо предоставления тестов низковго уровня, которые гарантируют, что каджый класс работает так, как ожидается, имеет смысл написать тесты высокого уровня, гарантирующие, что как минимум все, с чем сталкивается пользователь, будет работать хотя бы на внешнем уровне. Такие типы тестов зачастую называются тестами End-to-End, так как они охватывают все приложение от того, что видит ваш пользователь в браузере, до самого кода, который выполняется и создает связи с сервисами, вроде БД. Чтобы автоматизировать это, вы должны убедиться, что вы можете сделать так, чтобы экземпляр теста для вашей системы выполнялся максимально легко, и что внешние системы не изменяют ваше окружение производства, например, чтобы предоставить отдельный тест БД с (анонимными) данными из окружения производства. Так как эти тесты не особо полагаются на изоляцию тестируемого кода, а вместо этого смотрят на взаимосвязанную систему, их написание обычно легче и более продуктивное во время миграции. Вы затем можете сосредоточить свои усилия на написании тестов нижнего уровня для тех частей кода, которые вы должны изменить или заменить в новом приложении, чтобы гарантировать, что оно будет тестируемым с самого начала.

Существуют инструменты, направленные на тестирование End-to-End, которые вы можете использовать, вроде Symfony Panther, или вы можете написать функциональные тесты в новом приложении Symfony, как только будет завершена первоначальная установка. Например, вы можете добавить так называемые выборочные тесты, которые гарантируют только доступность каждого пути, проверяя возвращаемый HTTP статус-код, или ищут отрывок текста на странице.

Введение Symfony в существующее приложение

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

Tip

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

Запуск Symfony в фронт-контроллере

При рассмотрении первичного запуска типичного PHP-приложения, существует два основных подхода. Сегодня большинство фреймфорков предоставляют так называемый фронт-контроллер, который действует точкой входа. Независимо от того, по какому пути URL вашего приложения вы хотите перейти, каждый запрос отправляется этому фронт-контроллеру, который затем определяет, какие части вашего приложения щагружать, например, какой контроллер и действие вызвать. Этот подход также использует Symfony, где public/index.php - это фронт- контроллер. Особенно в более старых приложениях было распространено, чтобы разные пути обрабатывались разными PHP-файлами.

В любом случае, вам нужно создать public/index.php, который запустит ваше приложение Symfony либо скопировав файл из рецепта FrameworkBundle, либо используя Flex и затребовав FrameworkBundle. Вам также скорее всего будет необходимо обновить ваш веб-сервер (например, Apache или nginx), чтобы он всегда использовал этот фронт-контроллер. Вы можете просмотреть конфигурацию веб-сервера, чтобы увидеть примеры того, как это может выглядеть. Например, при использовании Apache вы можете использовать Правила переписывания, чтобы гарантировать, что PHP-файлы игнорируются, а вызывается только index.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
RewriteEngine On

RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
RewriteRule ^(.*) - [E=BASE:%1]

RewriteCond %{ENV:REDIRECT_STATUS} ^$
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]

RewriteRule ^index\.php - [L]

RewriteCond %{REQUEST_FILENAME} -f
RewriteCond %{REQUEST_FILENAME} !^.+\.php$
RewriteRule ^ - [L]

RewriteRule ^ %{ENV:BASE}/index.php [L]

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

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

  • Фронт-контроллер с наследуемым мостом, который оставляет наследуемое приложение нетронутым и позволяет поэтапно мигрировать его в приложение Symfony.
  • Загрузчик наследумого маршрута, где наследуемое приложение интегрируется в Symfony поэтапно, с полностью интегрированным финальным результатом.

Фронт-контроллер с наследуемым мостом

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// public/index.php
use App\Kernel;
use App\LegacyBridge;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;

require dirname(__DIR__).'/vendor/autoload.php';

(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');

/*
 * Ядро будет всегда доступно глобально, позволяя вам иметь
 * доступ к нему из вашего существующего приложения, и через него
 * к сервис-контейнеру. Это позволяет представить новые функции в
 * существующем приложении.
 */
global $kernel;

if ($_SERVER['APP_DEBUG']) {
    umask(0000);

    Debug::enable();
}

if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? $_ENV['TRUSTED_PROXIES'] ?? false) {
    Request::setTrustedProxies(
      explode(',', $trustedProxies),
      Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO
    );
}

if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false) {
    Request::setTrustedHosts([$trustedHosts]);
}

$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);

if (false === $response->isNotFound()) {
    // Symfony успешно обработала маршрут.
    $response->send();
} else {
    LegacyBridge::handleRequest($request, $response, __DIR__);
}

$kernel->terminate($request, $response);

Существует 2 основных отклонения от изначального файла:

Строка 18
Во первых, $kernel доступен глобально. Это позволяет вам использовать функции Symfony внутри вашего существующего приложения, и предоставляет доступ к сервисам, сконфигурированным в вашем приложении Symfony. Это помогает вам подготовить собственный код к лучшей работе в рамках приложения Symfony до осуществления перехода. Например, заменив устаревшие или избыточные библиотеки компонентами Symfony.
Строки 41 - 46
Если Symfony обработала ответ, он отправляется; в противном случае LegacyBridge обрабатывает запрос.

Этот мост наследования отвечает за понимание того, какой файл должен быть загружен для того, чтобы обработать старую логику приложения. Это может быть либо фронт- контроллер, схожий с public/index.php Symfony, или особенный скриптовый файл, основанный на текущем маршруте. Базовый вид этого LegacyBridge может выглядеть как-то так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/LegacyBridge.php
namespace App;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class LegacyBridge
{

    /**
     * Сопоставьте входящий запрос с нужным файлом. Это
     * ключевая функция LegacyBridge.
     *
     * Только образец кода. Ваша реализация будет отличаться, в зависимости от
     * архитектуры унаследованного кода и способа его выполнения.
     *
     * Если ваше отображение сложное, вы можете захотеть написать модульные тесты
     * для проверки логики, поэтому это публичная статичность.
     */
    public static function getLegacyScript(Request $request): string
    {
        $requestPathInfo = $request->getPathInfo();
        $legacyRoot = __DIR__ . '/../';

        // Сопоставьте маршрут с унаследованным скриптом:
        if ($requestPathInfo == '/customer/') {
            return "{$legacyRoot}src/customers/list.php";
        }

        // Сопоставьте прямой вызов файла, например, вызов ajax:
        if ($requestPathInfo == 'inc/ajax_cust_details.php') {
            return "{$legacyRoot}inc/ajax_cust_details.php";
        }

        // ... и т.д.

        throw new \Exception("Unhandled legacy mapping for $requestPathInfo");
    }

    public static function handleRequest(Request $request, Response $response, string $publicDirectory): void
    {
        $legacyScriptFilename = LegacyBridge::getLegacyScript($request);

        // Возможно, (повторно) установите некоторые переменные окружения (например, для обработки форм,
        // которые постят в PHP_SELF):
        $p = $request->getPathInfo();
        $_SERVER['PHP_SELF'] = $p;
        $_SERVER['SCRIPT_NAME'] = $p;
        $_SERVER['SCRIPT_FILENAME'] = $legacyScriptFilename;

        require $legacyScriptFilename;
    }
}

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

Так как старый скрипт вызывается в рамках глобальной переменной, он уменьшит побочные эффекты старого кода, который иногда может требовать переменных из глобальной области. В то же время, так как ваше приложение Symfony будет всегда запускаться первым, вы можете получить доступ к контейнеру через переменную $kernel и затем извлечь любой сервис (используя getContainer()). Это может быть полезным, если вы хотите представить новые функции в ваше наследуемое приложение, не перенося все действие в новое приложение. Например, вы теперь можете использовать Переводчик Symfony в вашем старом приложении, или вместо использования вашей старой логики БД, вы можете использовать Doctrine для рефакторинга старых запросов. Это также позволит вам значительно улучшить код наследования, что облегчит процесс его перевода на новое приложение Symfony.

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

Загрузчик наследуемого машрута

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

Tip

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

Загрузчик наследуемого маршрута - это пользовательский загрузчик маршрута. Загрузчик наследуемого маршрута имеет функции, схожие с предыдущим LegacyBridge, но также является сервисом, зарегистрированным внутри компонента Маршрутизация Symfony:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/Legacy/LegacyRouteLoader.php
namespace App\Legacy;

use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

class LegacyRouteLoader extends Loader
{
    // ...

    public function load($resource, $type = null): RouteCollection
    {
        $collection = new RouteCollection();
        $finder = new Finder();
        $finder->files()->name('*.php');

        /** @var SplFileInfo $legacyScriptFile */
        foreach ($finder->in($this->webDir) as $legacyScriptFile) {
            // Предполагает, что все файлы наследования используют ".php" в качестве расширения
            $filename = basename($legacyScriptFile->getRelativePathname(), '.php');
            $routeName = sprintf('app.legacy.%s', str_replace('/', '__', $filename));

            $collection->add($routeName, new Route($legacyScriptFile->getRelativePathname(), [
                '_controller' => 'App\Controller\LegacyController::loadLegacyScript',
                'requestPath' => '/' . $legacyScriptFile->getRelativePathname(),
                'legacyScript' => $legacyScriptFile->getPathname(),
            ]));
        }

        return $collection;
    }
}

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

1
$ php bin/console debug:router

Для того, чтобы использовать эти маршруты, вам понадобится создать контроллер, который обрабатывает эти маршруты. Вы могли заметить атрибут _controller в предыдущем примере кода, который сообщает Symfony, какой контроллер вызывать, когда она пытается получить доступ к одному из наследуемых маршрутов. Сам контроллер затем может использовать другие атрибуты маршрута (т.е. requestPath и legacyScript), чтобы определить, какой скрипт вызывать, и обернуть вывод в класс ответа:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/Controller/LegacyController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\StreamedResponse;

class LegacyController
{
    public function loadLegacyScript(string $requestPath, string $legacyScript): StreamedResponse
    {
        return new StreamedResponse(
            function () use ($requestPath, $legacyScript): void {
                $_SERVER['PHP_SELF'] = $requestPath;
                $_SERVER['SCRIPT_NAME'] = $requestPath;
                $_SERVER['SCRIPT_FILENAME'] = $legacyScript;

                chdir(dirname($legacyScript));

                require $legacyScript;
            }
        );
    }
}

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

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