Апгрейд и рефакторинг PHP-проектов — теперь это просто с Rector

5dd452a7c377ac406475fb53771ab7d4.png

1f6bbf9aa3bb5ed64ee775f20c112598.pngПривет! Меня зовут Александр Володин.

Я PHP backend developer из компании Skyeng.
Опыт разработки более 8 лет.

С выходом PHP 8 мне захотелось скорее использовать все новые фичи релиза, поэтому я взял свой рабочий проект и… поправил весь код руками. Сначала это было интересно, затем монотонно, а к середине рефакторинга превратилось в наказание. Ох, PHP 8, ты классный, но второй такой рефакторинг я не потяну. И тогда я задался вопросом: есть ли такой инструмент, который автоматически переводил бы код на новую версию синтаксиса? Так я познакомился с Rector.

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

Содержание статьи:

  1. Знакомство с Rector

  2. Rector и обновление пакетов

  3. Rector и архитектурный рефакторинг

  4. Итоги и полезные материалы

Знакомство с Rector

Rector — это инструмент автоматического рефакторинга, одной из главных функций которого является перевод кода на новые версии PHP и популярных фреймворков. 

Мой первый опыт использования Rector был просто фантастический. Я взял проект на PHP 7.3 со следующими входными данными:

С помощью 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,
	]);
};

Я разделяю все правила на три категории:

  1. Апгрейд/даунгрейд. Эти правила переводят код на новые версию PHP или фреймворка. Даунгрейд же возможен под более старые версии PHP.

  2. Качество кода. Эти правила помогают оптимизировать логику кода, удалить мертвый код или, например, улучшить типизацию. Я советую использовать их как часть CI.

  3. Настраиваемые правила. Эти правила нельзя бездумно закинуть в конфиг. Они настраиваются под определенный контекст и о них я расскажу позже — в части «Rector и обновление пакетов».

Место Rector среди других инструментов

Сам создатель Rector привёл хорошую классификацию всех инструментов. Он разделил их на те, которые репортят проблемы, и те, которые их фиксят, а также те, которые занимаются код-стайлом и логикой:

Из классификации видно, что главная цель Rector — фикс проблем в логике.

В чём фишка Rector?

Может возникнуть вопрос: «А как же PHP CS Fixer, у которого тоже есть правила рефакторинга код-стайла для разных версий PHP? Почему-бы не развивать его и добавить такие-же правила как у Rector?». Да, правила там есть, но они достаточно примитивные. Например, в новой версии PHP позволено ставить запятую в конце списка аргументов. PHP CS Fixer это поправить может, но это его максимум. Он не поможет перевести код на более сложные новые фичи, такие как Атрибуты.

Это связано с тем, что такие код-стайл-фиксеры (как PHP CS Fixer) представляют исходный код в виде последовательности токенов. Rector же работает с абстрактным синтаксическим деревом (AST).

Если я захочу после оператора »=» поставить пробел, то это легко сделать, когда всё представлено в виде последовательности токенов. Но когда это AST, то можно анализировать и изменять целые ветки логики приложения, не обращая внимания на пробелы, переносы и прочие нюансы код-стайла.

Тем не менее, у Rector есть аналоги, которые похожи на него по целям и принципу работы, но он всё равно превосходит их во всём на голову:

Аналоги Rector и их сравнение

Я нашёл 2 других инструмента, которые способны провести апгрейд кода:

  1. Phabelio/Phabel

  2. Slevomat/coding-standard

Сравнение по GitHub

Когда я выбираю между несколькими инструментами, то сначала смотрю, насколько они популярны в GitHub.

824b3c7f5ab0388da33a841c6ed3e8df.png

Тут мы видим, что Phabel не очень популярен, скорее аутсайдер, чего не скажешь о Rector и Slevomat Coding Standard. Они идут прямо впритык, но у Rector больше звёзд, активности и релизов в месяц. Также у него достаточно быстро фиксятся проблемы (issues на github), которые закидывает сообщество. Это свидетельствует о том, что продукт активно поддерживается и развивается.

Сравнение по возможности апгрейда до PHP 8

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

bd1ef7ea2c5b7c51239f558a17407a3a.png

Phabel и Slevomat Coding Standard не покрывают половину фич PHP 8. Rector же покрывает все возможности, кроме Nullsafe оператора.

Также у Rector самый широкий диапазон поддержки версий PHP — у него есть правила для перехода начиная с версии 5.2, заканчивая последней на текущий момент — 8.2. У аналогов они начинаются только с 7-ой версии.

И это не говоря о том, что у Rector ещё есть правила для обновления под новые версии фреймворков, чего совсем нет у аналогов.

Таким образом: Rector — лучший инструмент для апгрейда кода.

Как начать rector«ить?

Чаще всего нельзя просто взять и отрефакторить код с помощью Rector. Поэтому расскажу про банальные шаги, которые нужно сделать перед этим процессом.

1. Улучшить покрытие тестами

3fb69d35473b818f61710a7170b867b1.png

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

Поэтому мой совет: перед тем, как заниматься автоматическим изменением кода, лучше сделать автоматическую проверку.

2. Настроить статический анализ

fdd0baa63a1c54c499bc2c0f0cc54244.png

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

Я рекомендую использовать Psalm 6–8 уровня и ниже или PHPStan 3–4 уровня и выше. Но если вы только начинаете внедрять это в свой проект, лучше использовать PHPStan, так как Rector при анализе ориентируется на него.

3. Задать код-стайл

2ae548e6b3d0cfb588d3edbf42fec287.png

Rector не заботится о банальном код-стайле. Он занимается более «высокими» вещами. Для контролирования код-стайла рекомендую выбрать один из этих инструментов и запускать его после работы Rector:

— PHP CS Fixer;
— Slevomat Coding Standard;
— Easy Coding Standard.

Новичкам советую начинать с Easy Coding Standard.

4. Настроить Rector

Теперь нужно настроить конфиг rector.php, чтобы он делал то, что надо, а что не надо — не делал:

4.1 Указать, что рефакторить, а что пропустить

В первую очередь нужно указать, какие директории в проекте рефакторить, а какие — скипнуть.

paths([
        __DIR__.'/src',
        __DIR__.'/tests',
    ]);
    $rectorConfig->skip([
        __DIR__.'/**/_generated/*',
    ]);
...

4.2 Применить параллельную обработку

Можно значительно повысить скорость работы Rector благодаря конфигурации parallel.

parallel(seconds: 360);
...

4.3 Настроить импорт имён

Когда Rector отрефакторит код, по умолчанию он выведет всё в виде fully-qualified class names (FQCN). Это сделает чтение кода неудобным, поэтому сразу рекомендую задать конфиги так, чтобы пространства имён импортировались.

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.

sets([
        LevelSetList::UP_TO_PHP_80,
        SymfonyLevelSetList::UP_TO_SYMFONY_54,
    ]);
...

Конфиг теперь выглядит так:

rector.php

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 для улучшения кода. Но обновлять в проектах, кроме нашего кода, приходится ещё сторонние пакеты. И тут сложностей не меньше.

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

bded3243e09738c8e0864a344dd51313.png

И тогда я заинтересовался, как этот процесс можно упростить. 

Проблемы, которые я выделил

Со стороны пользователя, который обновляет пакеты, всегда надо:

  • Изучить особенности новой версии (changelog) перед обновлением, чтобы проанализировать, сколько работы надо проводить.

  • Поправить deprecated, внести новые требования версии.

  • Отладить работу. Хотя в результате всё равно на что-то можно наткнуться и придётся дебажить.

Со стороны мейнтейнера важно:

  • Поддерживать обратную совместимость.

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

  • Консультировать или помогать по переходу на новые версии.

Поиск решения

Я обратился к опыту обновления Symfony-проектов. Если отбросить нюансы разных версий, можно выделить три важных этапа:

  1. Symfony Flex — обновление проекта (структуру, конфиги, индекс и kernel PHP) с помощью рецептов symfony/flex.

  2. Rector — обновление кода проекта с помощью наборов правил Rector. То есть мы с помощью Rector и набора правил SymfonyLevelSetList::UP_TO_SYMFONY_54, обновляем код, адаптируем его под новую версию Symfony.

  3. Composer — обновление самих пакетов Symfony. На этом этапе мы накатываем новые Symfony-bundles в готовый проект: composer update symfony/*.

Здесь интересует второй этап. Как Rector адаптирует код? Я решил тоже написать свои наборы правил. 

Пишем свои наборы правил

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

1. Задаём структуру для наборов правил в нашем пакете:


Я создал директорию utils/rector и в ней две важных папки: config, которая хранит сами наборы для перехода на новые версии, и src для класса с константами.

2. Создаём константы для наборов правил:

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

Также я добавил константу UP_TO_LAST_VER, которая заключает в себе все предыдущие наборы правил, тем самым позволяя нам обновляться с версии 3.2 сразу на 4.0. 

3. Пишем набор правил:

Предположим, в новой версии пакета 4.0, мы переместили пачку сервисов в другое место, тогда у нас изменится namespace. Но проект, который использует наш пакет, будет искать эти сервисы по старому namespace, из-за чего возникнет критическая ошибка. Для того, чтобы в проекте namespace изменился на новый, используем правило RenameNamespaceRector.

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

В итоге, набор правил для перехода на версию 4.0, будет выглядеть следующим образом:

ruleWithConfiguration(RenameNamespaceRector::class, [
        'Skyeng\CmsBundle\UI\EasyAdminField' => 'Skyeng\CmsBundle\Infrastructure\EasyAdmin\Field',
    ]);

    // меняет все вызовы метода findByValue на findByName
    $rectorConfig->ruleWithConfiguration(RenameMethodRector::class, [
        new MethodCallRename(FieldRepository::class, 'findByValue', 'findByName'),
        new MethodCallRename(FieldRepositoryInterface::class, 'findByValue', 'findByName'),
    ]);
};

И это далеко не всё, что мы можем сделать с кодом.

Возможности встроенных настраиваемых правил:

  • Удаление: интерфейсов,   классов, трейтов, аргументов функций и методов.

  • Переименование: неймспейсов, классов, интерфейсов, констант, свойств, функций и методов.

  • Трансформация: замена вызова одних функций или методов на другие, замена одних сервисов на другие, изменение аргументов.

Все их можно найти в полном списке правил.

Если возможностей недостаточно, то можно легко написать свои правила.

Как создать свои правила в Rector

Наше правило должно реализовывать интерфейс PhpRectorInterface с двумя методами:  

  1. getNodeTypes — дает список классов узлов, которые поддерживает правило;

  2. refactor — делает всю остальную работу по дополнительной проверке и изменению узла (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-тесты для своих правил. Подробнее об этом в документации.

Как теперь выглядит процесс обновления 

Со стороны мейнтейнера

Чек-лист релиза пакета выглядит так:

  1. Внести правки в код пакета.

  2. Написать набор правил для перехода на новую версию: CmsSetRule::VER_40.

  3. Выпустить релиз новой версии 4.0.

Со стороны пользователя пакета или бандла 

Нужно выполнить три шага:

  1. Обновить пакет: composer update skyeng/cms-bundle;

  2. Добавить в конфиг rector.php набор правил: CmsSetRule::VER_40;

  3. Запустить Rector: vendor/bin/rector process.

Эти три шага позволят пользователю без лишних страданий обновлять пакет в проекте и не беспокоиться, что в коде что-то отвалилось и не работает.

Но если интересно, то и этот процесс можно ещё сократить:

Как еще упростить процесс с Composer Scripts

Для дальнейшей оптимизации мы используем Composer Scripts и подписываемся на события обновления пакетов.

# composer.json
{
    ...
    "scripts": {
        "post-package-update": [
            "App\\Infrastructure\\Composer\\EventHandler::postPackageUpdate"
        ],
    }
    ...
}

Пишем EventHandler для Composer со следующей логикой:

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 — которая всегда переводит на последнюю версию пакета.

sets([
        CmsSetList::UP_TO_LAST_VER,
    ]);
};

Готово. Теперь весь процесс обновления состоит из вызова одной команды:

composer update skyeng/cms-bundle

То есть она одновременно и обновляет пакет и адаптирует код проекта под новую версию пакета!

Преимущества подхода

Для пользователя:

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

  • За счёт автоматизации ускоряется переход на новую версию.

Для мейнтейнеров:

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

  • Снижается необходимость обеспечивать обратную совместимость. Но это зависит от популярности пакета.

  • Уменьшается количество работы с комьюнити, так как они будут вам писать только по поводу багов и предложений, а не потому, что они что-то не так обновили.

Rector и архитектурный рефакторинг

Напоследок хочу рассказать, как Rector помог мне с архитектурным рефакторингом.

Когда в проекте отсутствует чёткая стандартизация и структура, всё превращается в беспорядок. Допустим, я хочу написать новый сервис в проекте, который выглядит как на картинке слева. Куда его пихать? А хочется, чтобы всё выглядело как на картинке справа. Здесь я чётко понимаю, куда и что девать.

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

Нужно ускорить эту задачу, и здесь снова поможет Rector. Весь процесс похож на написание наборов правил для обновления пакета. С помощью правил описываем:

Как это сделать?

Сначала используем RenameNamespaceRector, RenameClassRector, то есть те самые настраиваемые правила. И создаём отдельный конфиг architect-rector.php:

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

Для автоматизации всего процесса, я описываю последовательности команд в конфиге утилиты task:

После того, как всё это тестируется и отлаживается, наступает день рефакторинга:

  1. Оповещаем команду, чтобы сегодня не трогали проект.

  2. Подтягиваем последние правки из мастера.

  3. Выполняем команду: > task refactor, которая проводит автоматическую трансформацию старой структуры на новую.

  4. Тестируем: проверяем, что всё ОК.

  5. Релизим на проде в этот же день.

В результате:

  1. Сокращается время, при котором команда не может трогать проект, до 1 дня;

  2. Можно спокойно откатывать правки по рефакторингу, если что-то пошло не так;

  3. Растёт качество и надёжность, потому что мы убрали человеческий фактор. Всё рефакторит Rector. 

  4. Можно использовать готовую настройку в аналогичном проекте. У меня было 2 похожих проекта, где я потом это всё запустил.

Итоги

  • Rector отлично подходит для апгрейда кода.

  • Rector может служить как дополнительный анализатор/фиксер качества кода.

  • Rector способен упростить процесс обновления пакетов.

  • Также Rector может помочь при архитектурном рефакторинге.

И да, Rector останется актуальным в будущем. Язык PHP, различные фреймворки и пакеты будут развиваться — появятся новые версии и возможности. А с ними могут возникнуть и новые проблемы. Для решения этих проблем мы будем вырабатывать практики. А сами эти практики — автоматизировать с помощью таких инструментов автоматического рефакторинга как Rector.

64761e525ed0f45aafba571daf60f020.gif

Чем больше проблем будет покрыто автоматическим рефакторингом, тем проще мы будем избавляться от легаси, и тем больше времени уделять фичам и развитию наших проектов!

Спасибо вам, что дочитали до этого момента, надеюсь было полезно.
Благословляю вас на все будущие рефакторинги!)

Полезные материалы:

© Habrahabr.ru