PHP Composer: фиксим зависимости без боли

в 10:01, , рубрики: composer, composer-patches, open source, php, Блог компании Badoo, Программирование

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

PHP Composer: фиксим зависимости без боли

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

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

Проблему удобнее всего рассматривать на примере. Давайте предположим, что мы хотим что-то изменить в библиотеке PHP Code Coverage, которая используется во фреймворке тестирования PHPUnit для измерения уровня покрытия кода тестами. Допустим, мы хотим исправить в версии 7.0.8 как-то так (файл myFix.patch):

diff --git a/src/CodeCoverage.php b/src/CodeCoverage.php
index 2c92ae2..514171e 100644
--- a/src/CodeCoverage.php
+++ b/src/CodeCoverage.php
@@ -190,6 +190,7 @@ public function filter(): Filter
      */
     public function getData(bool $raw = false): array
     {
+        // for example some changes here
         if (!$raw && $this->addUncoveredFilesFromWhitelist) {
             $this->addUncoveredFilesFromWhitelist();
         }

Создадим нашу библиотеку-пример. Пусть это будет php-composer-patches-example. Детали здесь не очень важны, но на случай если вы решите посмотреть, что из себя представляет библиотека, я привожу консольный вывод под спойлером.

Скрытый текст

$ git clone git@github.com:mougrim/php-composer-patches-example.git
Клонирование в «php-composer-patches-example»…
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Получение объектов: 100% (3/3), готово.
$ cd php-composer-patches-example/
$
$ composer.phar init --name=mougrim/php-composer-patches-example --description="It's an example for article with using forks and patches for changing dependencies" --author='Mougrim <rinat@mougrim.ru>' --type=library --require='phpunit/phpunit:^8.4.2' --license=MIT --homepage='https://github.com/mougrim/php-composer-patches-example'    

  Welcome to the Composer config generator  

This command will guide you through creating your composer.json config.

Package name (<vendor>/<name>) [mougrim/php-composer-patches-example]: 
Description [It's an example for article with using forks and patches for changing dependencies]: 
Author [Mougrim <rinat@mougrim.ru>, n to skip]: 
Minimum Stability []: 
Package Type (e.g. library, project, metapackage, composer-plugin) [library]: 
License [MIT]: 

Define your dependencies.

Would you like to define your dev dependencies (require-dev) interactively [yes]? no

{
    "name": "mougrim/php-composer-patches-example",
    "description": "It's an example for article with using forks and patches for changing dependencies",
    "type": "library",
    "homepage": "https://github.com/mougrim/php-composer-patches-example",
    "require": {
        "phpunit/phpunit": "^8.4.2"
    },
    "license": "MIT",
    "authors": [
        {
            "name": "Mougrim",
            "email": "rinat@mougrim.ru"
        }
    ]
}

Do you confirm generation [yes]? yes
Would you like to install dependencies now [yes]? yes
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 29 installs, 0 updates, 0 removals
  - Installing sebastian/version (2.0.1): Loading from cache
  - Installing sebastian/type (1.1.3): Loading from cache
  - Installing sebastian/resource-operations (2.0.1): Loading from cache
  - Installing sebastian/recursion-context (3.0.0): Loading from cache
  - Installing sebastian/object-reflector (1.1.1): Loading from cache
  - Installing sebastian/object-enumerator (3.0.3): Loading from cache
  - Installing sebastian/global-state (3.0.0): Loading from cache
  - Installing sebastian/exporter (3.1.2): Loading from cache
  - Installing sebastian/environment (4.2.2): Loading from cache
  - Installing sebastian/diff (3.0.2): Loading from cache
  - Installing sebastian/comparator (3.0.2): Loading from cache
  - Installing phpunit/php-timer (2.1.2): Loading from cache
  - Installing phpunit/php-text-template (1.2.1): Loading from cache
  - Installing phpunit/php-file-iterator (2.0.2): Loading from cache
  - Installing theseer/tokenizer (1.1.3): Loading from cache
  - Installing sebastian/code-unit-reverse-lookup (1.0.1): Loading from cache
  - Installing phpunit/php-token-stream (3.1.1): Loading from cache
  - Installing phpunit/php-code-coverage (7.0.8): Loading from cache
  - Installing doctrine/instantiator (1.2.0): Loading from cache
  - Installing symfony/polyfill-ctype (v1.12.0): Loading from cache
  - Installing webmozart/assert (1.5.0): Loading from cache
  - Installing phpdocumentor/reflection-common (2.0.0): Loading from cache
  - Installing phpdocumentor/type-resolver (1.0.1): Loading from cache
  - Installing phpdocumentor/reflection-docblock (4.3.2): Loading from cache
  - Installing phpspec/prophecy (1.9.0): Loading from cache
  - Installing phar-io/version (2.0.1): Loading from cache
  - Installing phar-io/manifest (1.0.3): Loading from cache
  - Installing myclabs/deep-copy (1.9.3): Loading from cache
  - Installing phpunit/phpunit (8.4.2): Loading from cache
sebastian/global-state suggests installing ext-uopz (*)
phpunit/phpunit suggests installing phpunit/php-invoker (^2.0.0)
phpunit/phpunit suggests installing ext-soap (*)
Writing lock file
Generating autoload files
$
$ echo 'vendor/' > .gitignore
$ echo 'composer.lock' >> .gitignore
$ git add .gitignore composer.json
$
$ git commit --gpg-sign --message='Init composer'
[master ce800ae] Init composer
 2 files changed, 18 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 composer.json
$ git push origin master
Подсчет объектов: 4, готово.
Delta compression using up to 4 threads.
Сжатие объектов: 100% (3/3), готово.
Запись объектов: 100% (4/4), 1.21 KiB | 1.21 MiB/s, готово.
Total 4 (delta 0), reused 0 (delta 0)
To github.com:mougrim/php-composer-patches-example.git
   f31c342..ce800ae  master -> master

Что не так с форком зависимости

Давайте посмотрим, как происходит форк зависимости. Попробуем форкнуть PHP Code Coverage.

  1. Заходим на страницу PHP Code Coverage на GitHub.
  2. Нажимаем на кнопку Fork Fork button (обратите внимание: у вас будет свой форк, замените mougrim на своё user name).
  3. Клонируем форк:
    cd ../
    git clone git@github.com:mougrim/php-code-coverage.git
    cd php-code-coverage
  4. Переходим в версию, которую мы хотим пропатчить:
    git checkout 7.0.8
  5. Создаём ветку для фикса:
    git checkout -b 7.0.8-myFix
  6. Вносим необходимые изменения, коммитим, пушим:
    git apply ../myFix.patch
    git add src/CodeCoverage.php
    git commit --gpg-sign --message='My fix'
    git push -u origin 7.0.8-myFix
  7. Добавляем форк как репозиторий в composer.json для нашей библиотеки (это нужно для того, чтобы при подключении пакета phpunit/php-code-coverage подключался не оригинальный пакет, а форк):
    cd ../php-composer-patches-example
    git checkout -b useFork
    composer.phar config repositories.phpunit/php-code-coverage vcs https://github.com/mougrim/php-code-coverage.git
  8. Меняем версию для зависимости на бранч:
    composer.phar require phpunit/php-code-coverage 'dev-7.0.8-myFix'

Но на самом деле всё ещё сложнее: Composer говорит, что установка невыполнима, так как phpunit/phpunit требует phpunit/php-code-coverage версии ^7.0.7, а для нашего проекта требуется dev-7.0.8-myFix:

$ composer.phar require phpunit/php-code-coverage 'dev-7.0.8-myFix'
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)         
Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - phpunit/phpunit 8.4.2 requires phpunit/php-code-coverage ^7.0.7 -> satisfiable by phpunit/php-code-coverage[7.0.x-dev].
    - phpunit/phpunit 8.4.2 requires phpunit/php-code-coverage ^7.0.7 -> satisfiable by phpunit/php-code-coverage[7.0.x-dev].
    - phpunit/phpunit 8.4.2 requires phpunit/php-code-coverage ^7.0.7 -> satisfiable by phpunit/php-code-coverage[7.0.x-dev].
    - Can only install one of: phpunit/php-code-coverage[7.0.x-dev, dev-7.0.8-myFix].
    - Installation request for phpunit/php-code-coverage dev-7.0.8-myFix -> satisfiable by phpunit/php-code-coverage[dev-7.0.8-myFix].
    - Installation request for phpunit/phpunit ^8.4.2 -> satisfiable by phpunit/phpunit[8.4.2].

Installation failed, reverting ./composer.json to its original content.

Что с этим делать? Есть три варианта:

  1. Помимо форка phpunit/php-code-coverage, форкнуть ещё и PHPUnit и прописать для зависимости phpunit/php-code-coverage версию dev-7.0.8-myFix. Этот путь довольно сложный с точки зрения поддержки и тем сложнее, чем больше библиотек зависит от phpunit/php-code-coverage.
  2. Сделать в своём форке phpunit/php-code-coverage, чтобы тег 7.0.8 ссылался на другой коммит. Это как минимум неочевидно, а как максимум — в Git неудобно работать с тегами, ссылающимися на разные коммиты с одним названием в разных удалённых репозиториях.
  3. В своём форке phpunit/php-code-coverage использовать тег альфа-релиза, например 7.0.8-a+myFix (здесь могут быть коллизии с альфа-релизами исходной библиотеки).

У всех вариантов есть свои недостатки. Я также пробовал использовать тег типа 7.0.8.1, но Composer не принимает такие теги.

Третий вариант кажется меньшим из зол, рассмотрим его подробнее. Создадим тег альфа-релиза:

cd ../php-code-coverage
git tag 7.0.8-a+myFix
git push origin 7.0.8-a+myFix
cd ../php-composer-patches-example
composer.phar require phpunit/php-code-coverage '7.0.8-a+myFix'
git add composer.json
git commit --gpg-sign --message='Use fork'
git push -u origin useFork

Допустим, мы хотим использовать нашу библиотеку mougrim/php-composer-patches-example в проекте, который зависит от phpunit/phpunit. Здесь тоже не обойтись без шаманства, придётся опять указать репозиторий https://github.com/mougrim/php-code-coverage.git для phpunit/php-code-coverage, а также явно указать зависимость от phpunit/php-code-coverage версии 7.0.8-a+myFix (иначе установка не завершится успехом):

cd ../
mkdir php-project
cd php-project/
composer.phar require phpunit/phpunit '^8.4.2'
composer.phar config repositories.mougrim/php-composer-patches-example vcs https://github.com/mougrim/php-composer-patches-example.git
composer.phar config repositories.phpunit/php-code-coverage vcs https://github.com/mougrim/php-code-coverage.git
composer.phar require phpunit/php-code-coverage 7.0.8-a+myFix
composer.phar require mougrim/php-composer-patches-example dev-useFork

Прошу обратить внимание на то, что php-composer-patches-example подключается как репозиторий, поскольку этот репозиторий является лишь примером и потому не был добавлен в Packagist. В вашем случае этот шаг, скорее всего, можно пропустить.

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

Плюсы этого подхода:

  • не нужно устанавливать плагины для Composer.

Минусы этого подхода:

  • если вы используете roave/security-advisories, то вы не увидите информацию о том, что версия зависимости, которую вы форкнули и модифицировали, содержит уязвимость;
  • когда выйдет новая версия зависимости, историю с форком придётся повторить заново;
  • если вы хотите зафиксить зависимость зависимости, как в рассмотренном примере, то dev-* для неё не подойдёт и придётся шаманить с версиями или делать форки конфликтующих зависимостей;
  • при наличии проектов, которые зависят от вашей библиотеки, устанавливать библиотеку в проект придётся не самым очевидным и удобным способом;
  • при наличии проектов, которые зависят от вашей библиотеки, для них версия phpunit/php-code-coverage будет строго зафиксирована, что не всегда приемлемо;
  • более того, если проекты из пунктов выше уже форкнули PHP Code Coverage по какой-то другой причине, то всё становится ещё сложнее.

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

Использование cweagans/composer-patches

В очередной раз испытывая боль и страдания от использования форков, я наткнулся на cweagans/composer-patches в PHP-Дайджесте № 101 (кстати, у pronskiy полезный блог, рекомендую подписаться). Это плагин для Cоmposer, который позволяет применять патчи к зависимостям. Прочитав описание, я подумал, что это именно то, что нужно.

Как использовать cweagans/composer-patches:

  1. Клонируем PHP Code Coverage:
    cd ../
    rm -rf php-code-coverage
    git clone git@github.com:sebastianbergmann/php-code-coverage.git
    cd php-code-coverage
  2. Переходим в версию, которую мы хотим пропатчить:
    git checkout 7.0.8
  3. Вносим необходимые изменения.
  4. Создаём патч:
    mkdir -p ../php-composer-patches-example/patches/phpunit/php-code-coverage
    git diff HEAD > ../php-composer-patches-example/patches/phpunit/php-code-coverage/myFix.patch
  5. В своём проекте подключаем cweagans/composer-patches:
    cd ../php-composer-patches-example
    git checkout master
    composer.phar update
    git checkout -b cweagansComposerPatches
    composer.phar require cweagans/composer-patches '^1.6.7'
  6. Для настройки cweagans/composer-patches добавляем следующее в composer.json (для одного пакета можно указать несколько патчей):
    {
        "config": {
            "preferred-install": "source"
        },
        "extra": {
            "patches": {
                "phpunit/php-code-coverage": {
                    "My fix description": "patches/phpunit/php-code-coverage/myFix.patch"
                }
            },
            "enable-patching": true
        }
    }
  7. Обновляем зависимости:
    composer.phar update
  8. Если что-то пошло не так, это можно будет увидеть в выводе предыдущей команды, но на всякий случай можно проверить, что наши изменения применились:
    $ cat vendor/phpunit/php-code-coverage/src/CodeCoverage.php | grep example
        // for example some changes here
  9. Коммитим и пушим результат:
    git add composer.json patches/phpunit/php-code-coverage/myFix.patch
    git commit --gpg-sign --message='Use cweagans/composer-patches'
    git push -u origin cweagansComposerPatches

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

Создаём проект:

cd ../
rm -rf php-project
mkdir php-project
cd php-project
composer.phar require phpunit/phpunit '^8.4.2'

Добавляем в composer.json следущие строки:

{
    "extra": {
        "enable-patching": true
    }
}

Устанавливаем mougrim/php-composer-patches-example:

composer.phar config repositories.mougrim/php-composer-patches-example vcs https://github.com/mougrim/php-composer-patches-example.git
composer.phar require mougrim/php-composer-patches-example dev-cweagansComposerPatches

Казалось бы, при подключении пакета должна была быть попытка применить патч, но нет.
Обновляем пакеты, чтобы применился патч, но этого не происходит:

$ composer.phar update
Removing package phpunit/php-code-coverage so that it can be re-installed and re-patched.
  - Removing phpunit/php-code-coverage (7.0.8)
Loading composer repositories with package information
Updating dependencies (including require-dev)         
Package operations: 1 install, 0 updates, 0 removals
No patches supplied.
Gathering patches for dependencies. This might take a minute.
  - Installing phpunit/php-code-coverage (7.0.8): Loading from cache
  - Applying patches for phpunit/php-code-coverage
    patches/phpunit/php-code-coverage/myFix.patch (My fix description)
   Could not apply patch! Skipping. The error was: The "patches/phpunit/php-code-coverage/myFix.patch" file could not be downloaded: failed to open stream: No such file or directory

Writing lock file
Generating autoload files

Порывшись в баг-трекере, я нашёл баг File based patches aren't resolved in dependencies. Получается, нужно либо указывать URL до патча (а значит, скачивать его откуда-то), либо указывать путь до патча вручную в каждом проекте, где вы устанавливаете зависимость, требующую патчей.

Подведём итоги использования cweagans/composer-patches.

Плюсы этого подхода:

  • у плагина есть комьюнити;
  • roave/security-advisories не перестанет работать;
  • при выходе новой версии зависимости, если патч успешно применится, достаточно будет убедиться, что с новой версией всё работает (для минорных релизов с большой вероятностью всё заработает само, для мажорных — тоже есть вероятность, что ничего делать не придётся);
  • при наличии проектов, которые зависят от вашей библиотеки, для них версия phpunit/php-code-coverage не будет строго зафиксирована;
  • более того, в случае из пункта выше такой проект сможет применить свои патчи в PHP Code Coverage.

Минусы:

  • это плагин для Composer, а значит, при обновлении Composer он может сломаться;
  • нужно указывать настройку enable-patching=true, чтобы патчи применялись из зависимостей;
  • у основного мейнтейнера проекта нет много времени, чтобы им заниматься, поэтому он, как правило, принимает pull requests, но не особо развивает проект (например, у него были идеи для второй версии в задаче, но спустя три года мало что изменилось);
  • есть баг File based patches aren't resolved in dependencies, который доставляет неудобства и который уже три года висит в бэклоге;
  • нельзя использовать разные патчи для разных версий зависимостей.

Последний пункт стал для меня барьером. Сначала я завёл feature request. Мейнтейнер написал, что не хочет добавлять эту фичу в основной код, но во второй версии можно будет написать плагин (да, плагин для плагина для Composer). Перспективы выхода второй версии были туманными, поэтому я решил поискать альтернативы. Среди небольшого списка я не нашёл плагина, который бы поддерживался.

Лезть в код плагина не хотелось, поэтому я решил прошерстить форки — наверняка кто-то уже сталкивался с проблемой и решил её.

Использование Vaimo Composer Patches

В большинстве форков не было вообще никаких отличий от оригинала (зачем они вообще форкают?). Часть форков была сделана для pull requests, которые уже были слиты с основной библиотекой. Однако всё-таки нашёлся один интересный кандидат, который решал мою проблему, — Vaimo Composer Patches. На тот момент он ещё был оформлен как форк, но его мейнтейнер, похоже, делать pull requests не собирался. Среди прочего, например, он уже поменял имя пакета на vaimo/composer-patches. Но была и проблема: issues были отключены, то есть обратной связи с автором не было вообще. Также плагин не был размещён на Packagist.

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

Использование vaimo/composer-patches не отличается от использования предыдущего плагина, но можно указывать разные патчи для разных версий.

  1. Откатываем нашу библиотеку (удаление папки vendor необходимо, так как плагины cweagans/composer-patches и vaimo/composer-patches не очень совместимы между собой):
    cd ../php-composer-patches-example
    git checkout master
    rm -rf vendor/
    composer.phar update
  2. Выполняем пункты 1—4 из предыдущего раздела.
  3. В своём проекте подключаем vaimo/composer-patches:
    cd ../php-composer-patches-example
    git checkout -b vaimoComposerPatches
    composer.phar require vaimo/composer-patches '^4.20.2'
  4. Для настройки vaimo/composer-patches добавляем следующее в composer.json (документацию можно увидеть здесь):
    {
        "extra": {
            "patches": {
                "phpunit/php-code-coverage": {
                    "My fix description": {
                        "< 7.0.0": "patches/phpunit/php-code-coverage/myFix-leagcy.patch",
                        ">= 7.0.0": "patches/phpunit/php-code-coverage/myFix.patch"
                    }
                }
            }
        }
    }
  5. Обновляем зависимости:
    composer.phar update
  6. Если что-то пошло не так, это можно будет увидеть в выводе предыдущей команды, но на всякий случай можно убедиться, что наши изменения применились:
    $ cat vendor/phpunit/php-code-coverage/src/CodeCoverage.php | grep example
        // for example some changes here
  7. Коммитим и пушим результат:
    git add composer.json patches/phpunit/php-code-coverage/myFix.patch
    git commit --gpg-sign --message='Use vaimo/composer-patches'
    git push -u origin vaimoComposerPatches

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

Создаём проект и устанавливаем mougrim/php-composer-patches-example:

cd ../
rm -rf php-project
mkdir php-project
cd php-project
composer.phar require phpunit/phpunit '^8.4.2'
composer.phar config repositories.mougrim/php-composer-patches-example vcs https://github.com/mougrim/php-composer-patches-example.git
composer.phar require mougrim/php-composer-patches-example dev-vaimoComposerPatches

На всякий случай можно убедиться, что наши изменения применились:

$ cat vendor/phpunit/php-code-coverage/src/CodeCoverage.php | grep example
    // for example some changes here

Подведём итоги использования vaimo/composer-patches.

Плюсы этого плагина почти такие же, как у предыдущего, но ещё включают следующие:

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

Минусы:

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

Выводы

Подведём общие итоги:

  • использовать форки пакетов для каких-то мелких исправлений неудобно;
  • cweagans/composer-patches — хороший плагин, но развивается плохо, поэтому я его не рекомендую;
  • Vaimo Composer Patches — отличный плагин, хорошо решающий проблему фикса зависимостей, а также имеющий кучу настроек;
  • у Vaimo Composer Patches маленькое комьюнити, но я надеюсь, что эта статья его увеличит;
  • если в зависимости требуется сделать очень много изменений, то, возможно, проще прибегнуть к хардфорку (поддерживать форк независимо от исходной зависимости).

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

В Badoo мы используем Vaimo Composer Patches в двух случаях:

  • в SoftMocks для патчинга PHPUnit и PHP Code Coverage;
  • во внутреннем репозитории для фикса Webmozart Assert для совместимости с SoftMocks как временный фикс (пока SoftMocks не поддерживают конструкции array_map(array('static', 'valueToString')).

Ринат Ахмадеев, Sr. PHP Developer

Автор: mougrim

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js