Legacy и Laravel: Переписываем устаревшее приложение на современный фреймворк

Legacy to Laravel

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

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

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

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

Установка нового Laravel-приложения

Начнем с чистого листа, установив новое Laravel-приложение. Если у вас еще нет инсталлятора Laravel, то вы можете найти инструкции по его установке здесь. Теперь, для установки, запустите следующую команду в своем терминале:

laravel new upgrade-to-laravel

Перемещение Laravel в Legacy

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

  1. Создаем новый каталог legacy внутри устаревшего приложения
  2. Перемещаем все папки устаревшего приложения, содержащие PHP-файлы, с верхнего уровня в каталог legacy
  3. Перемещаем все файлы и папки установленной Laravel в корневую папку устаревшего приложения.
  4. Добавляем в Laravel следующий legacy-маршрут для отлова всех путей. Он должен быть самым последним в файле routes/web.php:
    Route::any('{path}', 'LegacyController@index')->where('path', '.*');
  5. Создаем legacy-контроллер со следующим методом:
    public function index()
    {
        ob_start();
        require app_path('Http') . '/legacy.php';
        $output = ob_get_clean();
    
        // не забудьте импортировать Illuminate\Http\Response
        return new Response($output);
    }

    Большинство устаревших приложений используют echo для вывода своего контента, соответственно, вызовы ob_start() и ob_get_clean() позволяют нам перехватывать этот вывод в переменную $output с помощью буферизации вывода, для того чтобы мы могли обернуть его в ответ Laravel.

  6. Создаем новый файл app/Http/legacy.php со следующим содержимым:
    // Предполагается, что входной точкой устаревшего приложения будет `legacy/index.php`
    require __DIR__.'/../../legacy/index.php';
  7. В зависимости от конфигурации legacy, старую точку входа возможно потребуется изменить, чтобы она находилась на уровень ниже в дереве каталогов. Например, в CodeIgniter 3.0, $system_path = 'system'; в index.php необходимо будет поменять на $system_path = '../legacy/system';.

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

После того, как все файлы будут на своих местах и ​все ​пути будут обновлены, мы можем открыть наше приложение в браузере и увидеть его главную страницу, это будет означать, что оно работает внутри приложения Laravel!

Весенняя уборка

Теперь, когда у нас есть legacy, работающее через Laravel, самое время сосредоточиться на нескольких важных принципах поддержки.

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

Во-вторых, мы можем столкнуться с конфигурационными настройками устаревшего приложении, которые лучше подходят для файла конфигурации (расположенного в /config), а затем ссылается на них через хелпер config. Любые ключи безопасности или другие конфиденциальные настройки, которые не должны передаваться в систему контроля версий, должны быть перемещены в переменные файла .env (прописанный в gitignored), а затем добавлены (с пустыми значениями) в .env.example. После того, как параметр был перемещен .env, мы также можем определить его в файле конфигурации, поскольку ссылки на переменные среды ограничиваются каталогом /config, и Laravel сможет закэшировать нашу конфигурацию для повышения производительности в продакшене.

Тестирование домашней страницы

Предположим, что для устаревшего приложения нет тестов. Вы уверены, что всё работает, но процесс конвертации приложения создает риск того, что что-то может сломаться. Мы можем использовать встроенные функции тестирования Laravel, чтобы убедиться, что приложение продолжает работать.

Давайте создадим тест для домашней страницы, выполнив в терминале следующее:

php artisan make:test HomePageTest

Теперь давайте добавим тест, который гарантирует успешную загрузку домашней страницы:

public function testExample()
{
    // точка входа в старое приложение
    $response = $this->get('/');

    // замените 'welcome' на строку, имеющуюся на вашей главной странице
    $response->assertStatus(200)
        ->assertSee('Welcome');
}

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

Суперглобальные переменные PHP

Если legacy ссылается на суперглобальные переменные, такие как $_REQUEST или $_SERVER, то их следует изменить так, чтобы они вместо этого использовали хелпер request. $_REQUEST['some_param'], $_GET['some_param'] и $_POST['some_param'] могут быть изменены на request('some_param'), а $_SERVER['some_prop'] могут быть изменены на request()->server('some_prop').

Глобальные переменные

Любые тесты, запускающие код, ссылающийся на глобальную переменную, завершатся ошибкой, если тест сначала не запустит код, в котором инициализируется переменная. Это часто можно решить, переместив объявление глобальной переменной в app/Http/legacy.php, но лучший вариант — полностью избавиться от глобальной переменной и переместить ее, либо в файл конфигурации (если это простое значение), либо в сервис-контейнер, если это объект.

Токены CSRF

По умолчанию Laravel защищает все отправляемые формы с помощью CSRF-токена через мидлвар VerifyCsrfToken в группе маршрутов web, заданной в app/Http/Kernel.php. Чтобы воспользоваться преимуществом этой защиты, нам нужно будет включить CSRF-токен в каждую форму и POST-запрос. Хелпер csrf_field() сгенерирует скрытое поле с токеном.

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

Чтобы создать новую группу маршрутов, без мидлвара для CSRF, нам сначала нужно задать её в app/Http/Kernel.php:

// в $middlewareGroups ниже 'web'
'legacy' => [
    \App\Http\Middleware\EncryptCookies::class,
    \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
    \Illuminate\Session\Middleware\StartSession::class,
    // \Illuminate\Session\Middleware\AuthenticateSession::class,
    \Illuminate\View\Middleware\ShareErrorsFromSession::class,
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Затем мы переместим наш legacy маршрут в app/routes/legacy.php:

// app/routes/legacy.php
Route::any('{path}', 'LegacyController@index')->where('path', '.*');

Наконец, нам нужно зарегистрировать этот файл маршрутов, в app/Providers/RouteServiceProvider.php:

public function map()
{
    $this->mapApiRoutes();
    $this->mapWebRoutes();
    // Добавьте эту строку в метод map()
    $this->mapLegacyRoutes();
}

// Добавьте этот метод в конец класса
protected function mapLegacyRoutes()
{
    Route::middleware('legacy')
         ->namespace($this->namespace)
         ->group(base_path('routes/legacy.php'));
}

Теперь наши старые формы будут продолжать работать, также как и раньше, но без CSRF-защиты, в то время как любые новые маршруты отправки форм могут быть заданы в routes/web.php как обычно с использованием токенов безопасности.

Переход на Eloquent

Если legacy имеет очень сложный слой доступа к данным (data access layer — DAL), то идея переноса всей этой логики на Eloquent ORM Laravel может показаться устрашающей. Чтобы облегчить эту боль и минимизировать риск, мы разделим этот процесс на два этапа:

Сбор кода базы данных

Как правило, DAL будет содержать код с двумя обязанностями:

  1. Получение или изменение данных из базы данных
  2. Действия с данными, полученными из базы данных

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

Допустим, есть таблица items, и код, отвечающий за выборку одной или нескольких строк и выполнение операций с каждым элементом. Давайте начнем собирать этот код в Eloquent Shim.

Сначала нужно создать Eloquent-модель для таблицы items.

php artisan make:model Item

Затем создать файл для Eloquent Shim по адресу app/EloquentShims/ItemShim.php:

namespace App\EloquentShims;

class ItemShim
{
    protected $item;

    public function __construct($item)
    {
        $this->item = $item;
    }
}

Теперь мы готовы собирать код. Представьте, что у нас есть следующее:

// В методе контроллера
$items = OldItemModel::getFutureItems();

// OldItemModel.php
public static function getFutureItems()
{
    // запрос к БД
}

Сначала мы заменим код контроллера следующим:

$items = ItemShim::getFutureItems();

Затем мы переместим метод getFutureItems из класса OldItemModel в наш новый ItemShim.

// ItemShim
public static function getFutureItems()
{
    // запрос к БД
}

Мы можем продолжить сбор кода в классы прокладки аналогичным образом. Например, когда мы встречаем код, который выбирает одну запись по ID, то мы можем найти этот элемент через Eloquent, и создать прокладку следующим образом:

$item = new ItemShim(Item::find(123));

Любые методы, вызываемые для объекта $item, теперь можно перемещать из OldItemModel в ItemShim.

Рефакторинг кода прокладки

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

// ItemShim
public static function getFutureItems()
{
    // запрос к БД
}

Он может содержать любую логику, но представим, что он содержит сырой SQL-запрос, извлекающий элементы future из базы данных. Следуя соглашениям Laravel, релизуем скоуп-запрос в Eloquent-модели Item, чтобы отрефакторить до следующего кода:

// ItemShim
public static function getFutureItems()
{
    return Item::future()->get();
}

На этом этапе наша Eloquent-модель содержит всю нужную логику и метод прокладки getFutureItems больше не нужен. Заменим вызов прокладки в методе контроллера напрямую на Eloquent.

$items = Item::future()->get();

После того, как все вызовы getFutureItems заменены Eloquent’ом, то можно удалить метод getFutureItems из прокладки. Далее будем повторять этот процесс до тех пор, пока прокладка не станет пустой, а затем мы можем просто удалить её.

Ранние остановки исполнения

Legacy PHP-код часто использует команды die и exit для остановки приложения. Это мешать Laravel-циклу запрос/ответ, ломает расширения, типа Laravel Telescope, и не даёт LegacyController вернуть ответ. Приложение продолжает выполнять свои обязанности, но любые попытки написать PHPUnit-тесты, покрывающие этот код, будут неудачны, потому что процесс PHP остановится раньше и ни один из наших ассертов не будет выполнен.

Один из способов сохранения существующей функциональности, который позволит PHP продолжить исполнения, — это удаление die и exit, а вместо этого выбрасывать/перехватывать исключения. Чтобы сгенерировать исключение, которое мы можем перехватить, создадим новый файл app/Exceptions/LegacyExitScript.php:

namespace App\Exceptions;

use Exception;

class LegacyExitScript extends Exception
{

}

Затем мы обновим метод LegacyController@index для перехвата этих исключений и завершения запроса (не забудьте импортировать исключение LegacyExitScript!):

public function index()
{
    try {
        ob_start();
        require public_path() . '/legacy.php';
        $output = ob_get_clean();
    } catch (LegacyExitScript $e) {
        $output = ob_get_clean();
    }

    return new Response($output);
}

Теперь мы можем заменить вызовы die и exit на:

throw new LegacyExitScript;

Если таких вызовов слишком много и нет возможности сразу протестировать их замену, то возможно стоит всё это делать постепенно, покрывая PHPUnit-тестами.

Laravel Views

Пока мы стремимся воспользоваться преимуществами Laravel, наше приложение должно продолжать решать бизнес-задачи клиентов. Например, нам нужно отобразить новую страницу по существующему маршруту и здесь нам помогут Laravel-шаблоны (views). Перенос кода всего маршрута в Laravel-контроллер может занять слишком много времени, но есть другое решение! Мы можем воспользоваться структурой, которую мы создали для нашего исключения LegacyExitScript, только будет выбрасывать не исключение, а шаблон (throwing a view). Создадим новый файл app/Exceptions/LegacyView.php:

namespace App\Exceptions;

use Exception;
use Illuminate\View\View;

class LegacyView extends Exception
{
    protected $view;

    public function __construct(View $view)
    {
        $this->view = $view;
    }

    public function getView()
    {
        return $this->view;
    }
}

Нужно перехватить это исключение в LegacyController@index и вернуть шаблон как ответ:

public function index()
{
    try {
        ob_start();
        require public_path() . '/legacy.php';
        $output = ob_get_clean();
    } catch (LegacyExitScript $e) {
        $output = ob_get_clean();
    } catch (LegacyView $e) {
        return $e->getView();
    }

    return new Response($output);
}

Мы можем выбросить исключение LegacyView в любом месте, где нужно вызвать шаблон, но, чтобы сделать вызовы более удобными, добавим глобальный хелпер:

function legacy_view($view = null, $data = [], $mergeData = [])
{
    throw new App\Http\LegacyView(
        view($view, $data, $mergeData)
    );
}

Наша глобальная функция использует ту же сигнатуру метода, что и встроенный Laravel-хелпер view. Теперь в любом месте legacy-кода, мы можем прервать его выполнение и отрендерить шаблон. Для этого просто нужно вызвать наш новый хелпер.

legacy_view('items.index', ['items' => $items]);
// всё, что находится ниже, не будет выполнено

Выбрасывание шаблона обёрнутого в исключение не особо применяемая практика, но помните, что на первом проходе мы не стремимся к совершенству. Подобно нашим Eloquent-прокладкам, исключение LegacyView — это очередная ступенька к совершенному приложению. Это временное решение и, когда они будут не нужны, мы их удалим.

Генерация Миграция для Базы Данных

Если legacy-приложение не использовало миграции, то нам-то они в будущем понадобятся, например для новой среды разработки или для заполнения пустой базы данных. Файлы миграции можно сгенерировать из существующей БД. Одним из самых простых способов сделать это — использование пакета oscarafdev/migrations-generator.

Хелперы

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

Создаём файл по адресу app/helpers/legacy.php, а затем автоматически подгрузим в переменной files в composer.json:

// Добавьте это в свойство «autoload» под «classmap»
"files": [
    "app/helpers/legacy.php"
]

После запуска в терминале composer dump-autoload  любой метод, который мы определим в app/helpers/legacy.php будет доступен глобально. Теперь, когда у нас есть удобное место для размещения этих методов, то, каждый раз, когда мы сталкиваемся с хелпером, вызываемым из перемещенного в Laravel кода, то, мы можем просто переместить указанный метод из его прежнего местоположения в app/helpers/legacy.php.

Хелпер Legacy-пути

Раз у нас есть место для хелперов, то можно начать добавлять туда новые методы. Как правило, legacy-код содержит довольно много операторов include и require для импортирования других файлов. В идеале указанные в них пути будут относительными и они правильно продолжат свою работу, но иногда в них используются полные пути. В подобных ситуациях может потребоваться указать новый путь к подпапке устаревшего приложения. Мы можем облегчить себе задачу, добавив хелпер-метод legacy_path в файл app/helpers/legacy.php:

function legacy_path($path = null)
{
    return base_path('legacy/' . $path);
}

Преобразование нативных PHP-сессий

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

Поиск и Замена

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

// найти: \$_SESSION\[(.+)] = (.+);
// заменить на: session([$1 => $2]);

// до
$_SESSION['foo'] = 'bar';

// после
session(['foo' => 'bar']);

Конвертация по маршруту

Другая стратегия — конвертировать нативные PHP-сессии в Laravel только при перемещении функций из устаревшего приложения на новые Laravel-маршруты. Соответственно, одновременно нужно конвертировать гораздо меньше сессий, нативные и Laravel-сессии будут существаовать одновременно, но, если переменная сессии сконвертирована, то нужно выполнить поиск и сконвертировать всё её использования по всему устаревшему приложению.

В заключение

Устаревшие приложения бывают разные, и конкретно для вашего legacy-приложения могут потребоваться специфические настройки и изменения, которые мы не рассмотрели. Но, мы надеемся, что эта статья послужит вам фундаментальной стратегией для преобразования устаревшего PHP-приложения в приложение на Laravel, без необходимости полного его переписывания и позволяя вам сразу же начать использовать все фишки фреймворка. Laravel сделан для того, чтобы разработчик был доволен и продуктивен и нет причины, по которой мы не могли поддерживать наши старые проекты, не используя все преимущества нашего любимого фреймворка!

Автор: Andrew Morgan
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.