Skip to content
...
Блог
Назад в PHP 8.1

Назад в PHP 8.1 ​

В прошлый раз Rector помогал мне переписывать тесты между фреймворками: Testo → PHPUnit и обратно, чтобы мутационное тестирование наконец смотрело на Testo снаружи. Правила конвертации живут в бридже Rector и с тех пор спокойно работают, помогая переводить разные проекты на Testo. Но в этот раз задача пришла с другой стороны.

Началось всё с того, что на очередном пивном мероприятии Кирилл Несмеянов выразил желание переехать на Testo в своём проекте PHPLRT. Агент на Opus 4.8 под скиллами из Testo, используя Rector, перегнал всё за несколько минут. Инфраструктура монорепозитория упростилась, код тестов стал чище, а полный прогон тестов ускорился на 0.5 секунды: 4 -> 3.5 сек. Все счастливы.

Но позже пришла новость: PHPLRT даунгрейдится с PHP 8.4 на PHP 8.1. Это стало вызовом не только для Кирилла, но и для меня: Testo написан под 8.2.

Пропасть в одну версию ​

Между 8.1 и 8.2 действительно немного. Но когда я прошёлся по кодовой базе, «просто readonly class» разросся до списка:

  • readonly class — в Testo их много, и они легко переписываются в обычные классы. Но с readonly был один нюанс (см. ниже).
  • \ReflectionMethod::hasPrototype() — метод появился в 8.2, а Testo через него ищет переопределённые методы при поиске тестов. На 8.1 это сразу фатал.
  • memory_reset_peak_usage() — тоже 8.2. Бенчмарки сбрасывают им пик памяти между итерациями.
  • fn(): never => throw ... — это уже не новый синтаксис, а баг самой 8.1, что never нельзя поставить возвращаемым типом, хотя тело — выражение throw. В 8.2 починили.

Не все эти пункты я бы хотел переписывать на 8.1. Код в текущем виде мне нравится: readonly-классы, hasPrototype() вместо try/catch вокруг getPrototype(). Опускать кодовую базу до 8.1 ради одной версии, которая как год уже не поддерживается — плохая сделка.

И тут снова вспомнился Rector. У него есть наборы даунгрейда, которые переписывают новый синтаксис в старый. Что если конвертировать Testo в 8.1 только на время тестирования? Исходники остаются на 8.2, а в CI перед запуском тестов под 8.1 код прогоняется через Rector.

Сначала контейнер ​

Прежде чем звать Rector, надо было убрать одну мину.

В Testo есть свой DI-контейнер со скоупами: каждый Test Suite и каждый тест получают дочерний скоуп. Некоторые сервисы клонируются в новый скоуп, чтобы состояние одного теста не протекло в другой, а некоторые (как раз readonly-объекты) переносятся как есть с тем пониманием, что они скорее всего не будут изменяться. Поэтому контейнер смотрит на флаг readonly для понимания иммутабельности сервиса.

Теперь представьте, что Rector снял readonly со всех классов. Контейнер перестанет узнавать общие сервисы и начнёт их клонировать. Сразу, может, ничего и не упадёт, но поведение изменится. Например, отчёты будут собираться только на один скоуп. Такие поломки ищутся долго.

Правильное решение — не гадать по модификатору, а сказать явно. Так в контейнере появился атрибут #[ScopeShared]: класс с ним живёт одним экземпляром на всё дерево скоупов независимо от того, readonly он или нет.

Заодно пришло время вынести контейнер в отдельный пакет — internal/container. До этого он жил в трёх проектах копипастой: в Testo, в Trap и в DLoad. Теперь все трое используют один пакет (Trap, DLoad), а в Testo нужные readonly-сервисы помечены атрибутом.

Теперь Rector ​

Сначала я прогнал даунгрейд локально: переписал всю кодовую базу в 8.1 и запустил на ней тесты под 8.1. Rector справился почти со всем, но именно «почти» и было интересно:

  • Правило для never не знало про стрелочные функции — оно понижало только обычные function(): never. Отправил фикс, а потом второй уже в наборе «с 8.2 на 8.1».
  • Правила для ReflectionMethod::hasPrototype() не было вовсе. Написал новое: вызов заменяется на try { getPrototype() } catch — getPrototype() есть с незапамятных времён и на отсутствие прототипа бросает исключение.
  • memory_reset_peak_usage() — тупик. Полифилл написать нельзя: у функции нет аналога в юзерленде, пик памяти сбрасывается только движком. В итоге в бенчмарках вызов обёрнут в function_exists(), а один тест, который сравнивает пиковое потребление двух алгоритмов, на 8.1 честно скипается — без сброса пика его измерение ничего не значит.

С этими правками Testo целиком перегоняется в 8.1, и весь набор тестов на 8.1 проходит. Локально задача решена.

Что делать в CI? ​

Дальше начинается настоящая задача. У себя в CI я могу прогнать Rector по кодовой базе перед тестами. Но PHPLRT ставит Testo через Composer, и там Testo лежит в vendor/ вместе с полусотней других пакетов. Как его туда доставить уже пониженным?

Первое, что приходит в голову — composer install --ignore-platform-req=php, а потом Rector по вендору. Composer поставит всё, не глядя на версию PHP, Rector понизит.

Не сработает! Если снять ограничение по PHP, Composer возьмёт последние версии всех пакетов. На платформе без ограничений это Symfony 8, которому нужен PHP 8.4. Rector, конечно, попробует понизить и его — но это уже совсем другой объём кода и совсем другой риск. Нужны пакеты, по возможности, сразу на 8.1 без всякого даунгрейда.

То есть пакеты делятся на два сорта:

  • Совместимые — у них есть версия под 8.1, просто не самая новая. Symfony, PSR-пакеты, почти весь вендор. Их даунгрейдить не нужно, нужно просто дать Composer'у выбрать правильную версию.
  • Конфликтующие — версии под 8.1 нет вообще, пакет с рождения написан под 8.2+. Вот их и надо понижать. В нашем случае это сам Testo и его плагины.

Но как их различить? Это может сделать только сам Composer в момент разрешения зависимостей.

Экшен ​

Отсюда родилась схема. Вместо того чтобы снимать ограничение по PHP, мы, наоборот, прибиваем платформу гвоздями: composer config platform.php 8.1. Теперь Composer решает зависимости так, будто он уже на 8.1. Для совместимых пакетов он сам выберет старшую подходящую версию — Symfony 6 вместо 8. А упадёт только на конфликтующих: «у пакета такого-то нет версии под PHP 8.1».

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

  1. Конфликтующий пакет копируется из vendor/ в сторону, в .php-downgrade/.
  2. В его composer.json фиксируется версия, а ограничение PHP опускается до >=8.1.
  3. Папка подключается в корневой composer.json как локальный path-репозиторий.
  4. composer update запускается снова. Прибитая платформа отсекает оригинал с Packagist, а наша копия с ослабленным require.php проходит. Если упал ещё какой-то пакет — он тоже уезжает в .php-downgrade/, и круг повторяется.

Когда все зависимости разрешились, одним проходом Rector по всем скопированным пакетам код конфликтующих пакетов понижается до 8.1.

Так и получился универсальный экшен: php-internal/actions/downgrade. Ему говоришь целевую версию PHP, а он ставит зависимости с максимально новыми версиями, какие только встают на эту платформу, и понижает лишь то, что иначе не встанет.

Понижать можно не только вендор, но также файлы самого проекта. Например, конфиг testo.php и сами тесты написаны под PHP 8.2 — имеет смысл понизить до 8.1. Пусть тесты остаются красивыми, а с остальным разберётся Rector.

yaml
- name: Install and downgrade for PHP 8.1
  if: matrix.php == '8.1'
  uses: php-internal/actions/downgrade@v1
  with:
    php-version: '8.1'
    dependency-versions: ${{ matrix.dependencies }}
    paths: tests testo.php

- name: Run Tests
  run: composer test:ci