Привет! Меня зовут Александр Володин.
Я PHP backend developer из компании Skyeng.
Опыт разработки более 8 лет.
С выходом PHP 8 мне захотелось скорее использовать все новые фичи релиза, поэтому я взял свой рабочий проект и... поправил весь код руками. Сначала это было интересно, затем монотонно, а к середине рефакторинга превратилось в наказание. Ох, PHP 8, ты классный, но второй такой рефакторинг я не потяну. И тогда я задался вопросом: есть ли такой инструмент, который автоматически переводил бы код на новую версию синтаксиса? Так я познакомился с Rector.
В статье поделюсь, как этот инструмент автоматического рефакторинга помогает обуздать легаси и автоматизировать обновление проектов и пакетов, чтобы процесс проходил эффективнее и малой кровью.
Содержание статьи:
Знакомство с Rector
Rector — это инструмент автоматического рефакторинга, одной из главных функций которого является перевод кода на новые версии PHP и популярных фреймворков.
Мой первый опыт использования Rector был просто фантастический. Я взял проект на PHP 7.3 со следующими входными данными:
-
Кол-во PHP-файлов: 608;
-
Кол-во строк PHP-кода: 28 976.
С помощью Rector я отрефакторил его под PHP 8, изменив более 7 тысяч строк кода! При этом на всё, включая настройку Rector, ушло полтора часа. Если бы я правил вручную, то это точно заняло бы кучу времени.
Rector разработал Томаш Вотруба. Он был на конференции PHP Russia в 2019 году, где в интерактивном режиме показал, как Rector улучшает качество кода. Вот его доклад.
У Rector есть неплохая документация и даже целая книга «The Power of Automated Refactoring» под авторством Томаша Вотруба и Матиаса Нобака. Ещё Томаш ведёт блог про Rector, где рассказывает про его новые фичи и практики применения, а также личный блог про обновления и различные технологии в целом. За этим очень интересно следить.
Rector — это по сути обёртка над PHP-Parser, которая использует PHPStan для анализа типов. Анализ руководствуется правилами (rules) — единицы рефакторинга, которые изменяют конкретную часть кода. Например, ужесточают типизацию:
Rector имеет более 400 правил рефакторинга, что очень много. Было бы неудобно искать из них нужные и добавлять по одному в конфиг. Поэтому схожие по смыслу правила объединяют в наборы (sets). Например, набор правил TYPE_DECLARATION
улучшает строгую типизацию в коде:
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->sets([
SetList::TYPE_DECLARATION,
]);
};
Я разделяю все правила на три категории:
-
Апгрейд/даунгрейд. Эти правила переводят код на новые версию PHP или фреймворка. Даунгрейд же возможен под более старые версии PHP.
-
Качество кода. Эти правила помогают оптимизировать логику кода, удалить мертвый код или, например, улучшить типизацию. Я советую использовать их как часть CI.
-
Настраиваемые правила. Эти правила нельзя бездумно закинуть в конфиг. Они настраиваются под определенный контекст и о них я расскажу позже — в части «Rector и обновление пакетов».
Место Rector среди других инструментов
Сам создатель Rector привёл хорошую классификацию всех инструментов. Он разделил их на те, которые репортят проблемы, и те, которые их фиксят, а также те, которые занимаются код-стайлом и логикой:
Из классификации видно, что главная цель Rector — фикс проблем в логике.
В чём фишка Rector?
Может возникнуть вопрос: «А как же PHP CS Fixer, у которого тоже есть правила рефакторинга код-стайла для разных версий PHP? Почему-бы не развивать его и добавить такие-же правила как у Rector?». Да, правила там есть, но они достаточно примитивные. Например, в новой версии PHP позволено ставить запятую в конце списка аргументов. PHP CS Fixer это поправить может, но это его максимум. Он не поможет перевести код на более сложные новые фичи, такие как Атрибуты.
Это связано с тем, что такие код-стайл-фиксеры (как PHP CS Fixer) представляют исходный код в виде последовательности токенов. Rector же работает с абстрактным синтаксическим деревом (AST).
Если я захочу после оператора «=» поставить пробел, то это легко сделать, когда всё представлено в виде последовательности токенов. Но когда это AST, то можно анализировать и изменять целые ветки логики приложения, не обращая внимания на пробелы, переносы и прочие нюансы код-стайла.
Тем не менее, у Rector есть аналоги, которые похожи на него по целям и принципу работы, но он всё равно превосходит их во всём на голову:
Аналоги Rector и их сравнение
Я нашёл 2 других инструмента, которые способны провести апгрейд кода:
Сравнение по GitHub
Когда я выбираю между несколькими инструментами, то сначала смотрю, насколько они популярны в GitHub.
Тут мы видим, что Phabel не очень популярен, скорее аутсайдер, чего не скажешь о Rector и Slevomat Coding Standard. Они идут прямо впритык, но у Rector больше звёзд, активности и релизов в месяц. Также у него достаточно быстро фиксятся проблемы (issues на github), которые закидывает сообщество. Это свидетельствует о том, что продукт активно поддерживается и развивается.
Сравнение по возможности апгрейда до PHP 8
Нельзя сравнивать только по популярности. Нужно сравнить ещё по возможностям, но абсолютно все их сравнить сложно. Поэтому я решил сравнить, как эти инструменты справятся с переходом на PHP 8 (актуальной по сей день проблемой для многих проектов):
Phabel и Slevomat Coding Standard не покрывают половину фич PHP 8. Rector же покрывает все возможности, кроме Nullsafe оператора.
Также у Rector самый широкий диапазон поддержки версий PHP — у него есть правила для перехода начиная с версии 5.2, заканчивая последней на текущий момент — 8.2. У аналогов они начинаются только с 7-ой версии.
И это не говоря о том, что у Rector ещё есть правила для обновления под новые версии фреймворков, чего совсем нет у аналогов.
Таким образом: Rector — лучший инструмент для апгрейда кода.
Как начать rector’ить?
Чаще всего нельзя просто взять и отрефакторить код с помощью Rector. Поэтому расскажу про банальные шаги, которые нужно сделать перед этим процессом.
1. Улучшить покрытие тестами
Тесты позволят вам быстро проверить результат рефакторинга Rector и провести отладку. Ручная проверка займет гораздо больше времени и это более монотонный процесс, чем даже сам рефакторинг.
Поэтому мой совет: перед тем, как заниматься автоматическим изменением кода, лучше сделать автоматическую проверку.
2. Настроить статический анализ
Статический анализ — это уже культура ведения кода. Но кроме этого он нужен Rector, чтобы эффективнее проводить рефакторинг.
Я рекомендую использовать Psalm 6-8 уровня и ниже или PHPStan 3-4 уровня и выше. Но если вы только начинаете внедрять это в свой проект, лучше использовать PHPStan, так как Rector при анализе ориентируется на него.
3. Задать код-стайл
Rector не заботится о банальном код-стайле. Он занимается более «высокими» вещами. Для контролирования код-стайла рекомендую выбрать один из этих инструментов и запускать его после работы Rector:
- PHP CS Fixer;
- Slevomat Coding Standard;
- Easy Coding Standard.
Новичкам советую начинать с Easy Coding Standard.
4. Настроить Rector
Теперь нужно настроить конфиг rector.php, чтобы он делал то, что надо, а что не надо — не делал:
<?php // rector.php
use RectorConfigRectorConfig;
return static function (RectorConfig $rectorConfig): void {
// здесь будем настраивать
}
4.1 Указать, что рефакторить, а что пропустить.
В первую очередь нужно указать, какие директории в проекте рефакторить, а какие — скипнуть.
<?php // rector.php
...
$rectorConfig->paths([
__DIR__.'/src',
__DIR__.'/tests',
]);
$rectorConfig->skip([
__DIR__.'/**/_generated/*',
]);
...
4.2 Применить параллельную обработку.
Можно значительно повысить скорость работы Rector благодаря конфигурации parallel.
<?php // rector.php
...
$rectorConfig->parallel(seconds: 360);
...
4.3 Настроить импорт имён.
Когда Rector отрефакторит код, по умолчанию он выведет всё в виде fully-qualified class names (FQCN). Это сделает чтение кода неудобным, поэтому сразу рекомендую задать конфиги так, чтобы пространства имён импортировались.
<?php // rector.php
...
$rectorConfig->importNames();
$rectorConfig->importShortClasses(false);
...
Можно задать опцию APPLY_AUTO_IMPORT_NAMES_ON_CHANGED_FILES_ONLY
, чтобы этот импорт происходил только в файлах, которые изменил рефакторинг, а не абсолютно во всем проекте.
$rectorConfig->parameters()->set(Option::APPLY_AUTO_IMPORT_NAMES_ON_CHANGED_FILES_ONLY, true);
Но я обычно сразу привожу весь код к единому стилю и не устанавливаю эту опцию.
4.4 Добавить правила апгрейдов.
Рекомендуют сразу добавлять константы, которые позволяют перевести вас с любой версии PHP (хоть с 7.1 или 5.3) сразу на 8. То же самое сделать и для Symfony.
<?php // rector.php
...
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_80,
SymfonyLevelSetList::UP_TO_SYMFONY_54,
]);
...
Конфиг теперь выглядит так:
rector.php
<?php declare(strict_types=1);
use RectorConfigRectorConfig;
use RectorCoreConfigurationOption;
use RectorSetValueObjectLevelSetList;
use RectorSymfonySetSymfonyLevelSetList;
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->paths([
__DIR__.'/src',
__DIR__.'/tests',
]);
$rectorConfig->skip([
__DIR__.'/**/_generated/*',
]);
$rectorConfig->parallel(seconds: 360);
$rectorConfig->importNames();
$rectorConfig->importShortClasses(false);
$rectorConfig->parameters()->set(Option::APPLY_AUTO_IMPORT_NAMES_ON_CHANGED_FILES_ONLY, true);
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_80,
SymfonyLevelSetList::UP_TO_SYMFONY_54,
]);
}
А вот ссылка на мой реальный рабочий конфиг.
5. Теперь можно rector’ить!
vendor/bin/rector process --clear-cache
Рекомендую запускать команду с --clear-cache, чтобы избежать ситуаций, когда кэш сформировался не полностью (например вы прервали предыдущий запуск Rector). Из-за этого он может обработать не все файлы.
Также команду можно выполнить в режиме --dry-run и посмотреть в консоли все предложения по рефакторингу.
Итоги этих работ
Может показаться, что предварительные работы отнимут немало времени. В каких-то заброшенных проектах — да. Но вы это сделаете один раз, а Rector будете запускать постоянно, потому что периодически будут выходить новые версии PHP, Symfony и других библиотек. Сам Rector развивается и в нем появляются новые правила оптимизации кода.
В конечном итоге Rector сэкономит вам много времени, сделает эту работу качественно и будет держать код на высоком уровне.
Rector и обновление пакетов
Надеюсь, я убедил вас использовать Rector для улучшения кода. Но обновлять в проектах, кроме нашего кода, приходится ещё сторонние пакеты. И тут сложностей не меньше.
У меня был печальный опыт поддержки пакета. Я смотрел на код, который просил архитектурной переделки, но был уже плотно интегрирован в исходный код других проектов из разных команд. Я проводил небольшие рефакторинги, но это всегда была непростая история как для меня, так и для тех, кто затем обновлял мой пакет у себя проекте.
И тогда я заинтересовался, как этот процесс можно упростить.
Проблемы, которые я выделил
Со стороны пользователя, который обновляет пакеты, всегда надо:
-
Изучить особенности новой версии (changelog) перед обновлением, чтобы проанализировать, сколько работы надо проводить.
-
Поправить deprecated, внести новые требования версии.
-
Отладить работу. Хотя в результате всё равно на что-то можно наткнуться и придётся дебажить.
Со стороны мейнтейнера важно:
-
Поддерживать обратную совместимость.
-
Растягивать выход мажорных релизов. Потому что нельзя каждую версию делать мажорной, иначе пользователи с ума сойдут каждый раз обновлять.
-
Консультировать или помогать по переходу на новые версии.
Поиск решения
Я обратился к опыту обновления Symfony-проектов. Если отбросить нюансы разных версий, можно выделить три важных этапа:
-
Symfony Flex — обновление проекта (структуру, конфиги, индекс и kernel PHP) с помощью рецептов symfony/flex.
-
Rector — обновление кода проекта с помощью наборов правил Rector. То есть мы с помощью Rector и набора правил
SymfonyLevelSetList::UP_TO_SYMFONY_54
, обновляем код, адаптируем его под новую версию Symfony. -
Composer — обновление самих пакетов Symfony. На этом этапе мы накатываем новые Symfony-bundles в готовый проект:
composer update symfony/*
.
Здесь интересует второй этап. Как Rector адаптирует код? Я решил тоже написать свои наборы правил.
Пишем свои наборы правил
Наборы правил Rector для обновления Symfony-проектов хранятся в отдельном репозитории. Для популярного опенсорс-пакета это хорошее решение. Но для небольшого бандла, который используется только в рамках организации это будет неудобно и расточительно, поэтому свои наборы правил я буду хранить прямо в пакете .
1. Задаём структуру для наборов правил в нашем пакете:
Я создал директорию utils/rector и в ней две важных папки: config, которая хранит сами наборы для перехода на новые версии, и src для класса с константами.
2. Создаём константы для наборов правил
<?php // utils/rector/src/Set/CmsSetList.php
declare(strict_types=1);
namespace SkyengCmsBundleUtilsRectorSet;
use RectorSetContractSetListInterface;
class CmsSetList implements SetListInterface
{
public const VER_32 = __DIR__.'/../../config/sets/ver_32.php';
public const VER_33 = __DIR__.'/../../config/sets/ver_33.php';
public const VER_34 = __DIR__.'/../../config/sets/ver_34.php';
public const VER_40 = __DIR__.'/../../config/sets/ver_40.php';
public const UP_TO_LAST_VER = __DIR__.'/../../config/sets/up_to_last_ver.php';
}
Здесь константы, которые ссылаются на наборы правил для каждой версии. Это такой юзер интерфейс Rector, чтобы потом было удобно подключать их в конфиге проекта.
Также я добавил константу UP_TO_LAST_VER
, которая заключает в себе все предыдущие наборы правил, тем самым позволяя нам обновляться с версии 3.2 сразу на 4.0.
3. Пишем набор правил
Предположим, в новой версии пакета 4.0, мы переместили пачку сервисов в другое место, тогда у нас изменится namespace. Но проект, который использует наш пакет, будет искать эти сервисы по старому namespace, из-за чего возникнет критическая ошибка. Для того, чтобы в проекте namespace изменился на новый, используем правило RenameNamespaceRector
.
Аналогичные действия мы можем сделать для методов, названия которых мы изменили или просто хотим, чтобы в проекте перешли на новую реализацию. Для этого используем RenameMethodRector
.
В итоге, набор правил для перехода на версию 4.0, будет выглядеть следующим образом:
<?php // utils/rector/config/sets/ver_40.php
declare(strict_types=1);
use ...;
return static function (RectorConfig $rectorConfig): void {
// меняет неймспейсы
$rectorConfig->ruleWithConfiguration(RenameNamespaceRector::class, [
'SkyengCmsBundleUIEasyAdminField' => 'SkyengCmsBundleInfrastructureEasyAdminField',
]);
// меняет все вызовы метода findByValue на findByName
$rectorConfig->ruleWithConfiguration(RenameMethodRector::class, [
new MethodCallRename(FieldRepository::class, 'findByValue', 'findByName'),
new MethodCallRename(FieldRepositoryInterface::class, 'findByValue', 'findByName'),
]);
};
И это далеко не всё, что мы можем сделать с кодом.
Возможности встроенных настраиваемых правил:
-
Удаление: интерфейсов, классов, трейтов, аргументов функций и методов.
-
Переименование: неймспейсов, классов, интерфейсов, констант, свойств, функций и методов.
-
Трансформация: замена вызова одних функций или методов на другие, замена одних сервисов на другие, изменение аргументов.
Все их можно найти в полном списке правил.
Если возможностей недостаточно, то можно легко написать свои правила.
Как создать свои правила в Rector
Наше правило должно реализовывать интерфейс PhpRectorInterface
с двумя методами:
-
getNodeTypes
— дает список классов узлов, которые поддерживает правило; -
refactor
— делает всю остальную работу по дополнительной проверке и изменению узла (Node).
<?php declare (strict_types=1);
namespace RectorCoreContractRector;
use PhpParserNode;
interface PhpRectorInterface
{
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array;
/**
* @return Node|Node[]|null
*/
public function refactor(Node $node);
}
Но лучше не реализовывать этот интерфейс напрямую, а наследоваться отAbstractRector
, так как там куча вспомогательных сервисов для анализа и манипуяции над узлами.
Правила в Rector применяются следующим образом:
-
Rector парсит код в AST.
-
Проходится по каждому узлу.
-
В каждом узле Rector перебирает все запущенные правила, вызывая у них метод
getNodeTypes
, тем самым сравнивая класс текущего узла с теми классами, что поддерживает правило. Если они совпадают, то узел обрабатывается в методеrefactor
.
Почти у всех правил алгоритм работы метода refactor
одинаковый:
-
Сначала проверяется то, что логически это тот самый узел, который нужно менять (например, проверяется родитель узла, его атрибуты, данные, и прочий контекст)
-
Если узел не проходит эти проверки, то возвращается
null
, что говорит о том, что узел не был изменен. -
Если узел прошел все проверки, то он изменяется и возвращается как результат работы метода
refactor
.
Совет: Правила должны быть идемпотентными, то есть их выполнение над одним и тем же кодом, должно приводить к одному и тому же результату.
Свои правила я храню в папке «/utils/rector/src» рядом с константами:
Также у Rector есть своя обёртка над PHPunit, которая позволяет удобно писать unit-тесты для своих правил. Подробнее об этом в документации.
Как теперь выглядит процесс обновления
Со стороны мейнтейнера
Чек-лист релиза пакета выглядит так:
-
Внести правки в код пакета.
-
Написать набор правил для перехода на новую версию:
CmsSetRule::VER_40
. -
Выпустить релиз новой версии 4.0.
Со стороны пользователя пакета или бандла
Нужно выполнить три шага:
-
Обновить пакет:
composer update skyeng/cms-bundle
; -
Добавить в конфиг rector.php набор правил:
CmsSetRule::VER_40
; -
Запустить Rector:
vendor/bin/rector process
.
Эти три шага позволят пользователю без лишних страданий обновлять пакет в проекте и не беспокоиться, что в коде что-то отвалилось и не работает.
Но если интересно, то и этот процесс можно ещё сократить:
Как еще упростить процесс с Composer Scripts
Для дальнейшей оптимизации мы используем Composer Scripts и подписываемся на события обновления пакетов.
# composer.json
{
...
"scripts": {
"post-package-update": [
"App\Infrastructure\Composer\EventHandler::postPackageUpdate"
],
}
...
}
Пишем EventHandler для Composer со следующей логикой:
<?php // src/Infrastructure/Composer/EventHandler.php
declare(strict_types=1);
namespace AppInfrastructureComposer;
use ComposerInstallerPackageEvent;
use ComposerUtilProcessExecutor;
class EventHandler
{
public static function postPackageUpdate(PackageEvent $event): void
{
// 1. Проверяем, что обновился нужный пакет.
if ($event->getOperation()->getTargetPackage()->getName() !== 'skyeng/cms-bundle') {
return;
}
// 2. Спрашиваем пользователя, хочет ли он, чтобы Rector
// обновил код. Не все люди любят, чтобы код автоматически
// изменялся, поэтому я добавил эту возможность.
if ('y' !== $event->getIO()->ask('Execute Rector script? [y,n]', 'y')) {
return;
}
// 3. Запускаем Rector с особым конфигом.
(new ProcessExecutor($event->getIO()))->execute(
'vendor/bin/rector process --config=cms-bundle-rector.php --clear-cache'
);
}
}
Создаем особый конфиг cms-bundle-rector.php , содержащий в наборах правил только одну константу CmsSetList::UP_TO_LAST_VER
- которая всегда переводит на последнюю версию пакета.
<?php // cms-bundle-rector.php
declare(strict_types=1);
use AppUtilsRectorCommonRectorConfig;
use RectorConfigRectorConfig;
use SkyengCmsBundleUtilsRectorSetCmsSetList;
return static function (RectorConfig rectorConfig){
$rectorConfig->sets([
CmsSetList::UP_TO_LAST_VER,
]);
};
Готово. Теперь весь процесс обновления состоит из вызова одной команды:
composer update skyeng/cms-bundle
То есть она одновременно и обновляет пакет и адаптирует код проекта под новую версию пакета!
Преимущества подхода
Для пользователя:
-
Не нужно погружаться в детали релиза, изучать, какие изменились или стали deprecated интерфейсы, сервисы, константы и так далее.
-
За счёт автоматизации ускоряется переход на новую версию.
Для мейнтейнеров:
-
Появляется возможность выпускать более радикальные релизы. Можно позволить себе более смелые правки, потому что процесс перехода автоматизирован. Нет никакой разницы, если правок много или мало.
-
Снижается необходимость обеспечивать обратную совместимость. Но это зависит от популярности пакета.
-
Уменьшается количество работы с комьюнити, так как они будут вам писать только по поводу багов и предложений, а не потому, что они что-то не так обновили.
Rector и архитектурный рефакторинг
Напоследок хочу рассказать, как Rector помог мне с архитектурным рефакторингом.
Когда в проекте отсутствует чёткая стандартизация и структура, всё превращается в беспорядок. Допустим, я хочу написать новый сервис в проекте, который выглядит как на картинке слева. Куда его пихать? А хочется, чтобы всё выглядело как на картинке справа. Здесь я чётко понимаю, куда и что девать.
Но проблема в том, что пока я буду адаптировать свой проект под такую структуру, в него будут вноситься изменения, катиться хотфиксы. Как результат, мы будем сталкиваться с кучей merge-конфликтов. Чтобы избежать их, надо стопорнуть всю команду и сказать, чтобы не трогали проект неделю или даже дольше. Но чаще всего, так делать нельзя.
Нужно ускорить эту задачу, и здесь снова поможет Rector. Весь процесс похож на написание наборов правил для обновления пакета. С помощью правил описываем:
-
Что и куда хотим переместить;
-
Что переименовать;
-
Что удалить.
Как это сделать?
Сначала используем RenameNamespaceRector
, RenameClassRector
, то есть те самые настраиваемые правила. И создаём отдельный конфиг architect-rector.php:
Но у Rector есть одно ограничение: он не меняет физическое местоположение файлов. Но это не проблема, физические манипуляции можно описать с помощью утилиты rsync.
Для автоматизации всего процесса, я описываю последовательности команд в конфиге утилиты task:
После того, как всё это тестируется и отлаживается, наступает день рефакторинга:
-
Оповещаем команду, чтобы сегодня не трогали проект.
-
Подтягиваем последние правки из мастера.
-
Выполняем команду:
> task refactor
, которая проводит автоматическую трансформацию старой структуры на новую. -
Тестируем: проверяем, что всё ОК.
-
Релизим на проде в этот же день.
В результате:
-
Сокращается время, при котором команда не может трогать проект, до 1 дня;
-
Можно спокойно откатывать правки по рефакторингу, если что-то пошло не так;
-
Растёт качество и надёжность, потому что мы убрали человеческий фактор. Всё рефакторит Rector.
-
Можно использовать готовую настройку в аналогичном проекте. У меня было 2 похожих проекта, где я потом это всё запустил.
Итоги
-
Rector отлично подходит для апгрейда кода.
-
Rector может служить как дополнительный анализатор/фиксер качества кода.
-
Rector способен упростить процесс обновления пакетов.
-
Также Rector может помочь при архитектурном рефакторинге.
И да, Rector останется актуальным в будущем. Язык PHP, различные фреймворки и пакеты будут развиваться — появятся новые версии и возможности. А с ними могут возникнуть и новые проблемы. Для решения этих проблем мы будем вырабатывать практики. А сами эти практики — автоматизировать с помощью таких инструментов автоматического рефакторинга как Rector.
Чем больше проблем будет покрыто автоматическим рефакторингом, тем проще мы будем избавляться от легаси, и тем больше времени уделять фичам и развитию наших проектов!
Спасибо вам, что дочитали до этого момента, надеюсь было полезно.
Благословляю вас на все будущие рефакторинги!)
Полезные материалы:
-
Видео-версия доклада с HighLoad++ PHP Russia 2022:
Автор: Александр Володин