При создании PHP-приложения или библиотеки обычно у вас есть три вида зависимостей:


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

Как управлять этими зависимостями?


Жёсткие зависимости:


{
    "require": {
        "acme/foo": "^1.0"
    }
}

Опциональные зависимости:


{
    "suggest": {
        "monolog/monolog": "Advanced logging library",
        "ext-xml": "Required to support XML"
    }
}

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


{
    "require-dev": {
      "monolog/monolog": "^1.0",
      "phpunit/phpunit": "^6.0"
    }
}

И так далее. Что может случиться плохого? Всё дело в ограничениях, присущих require-dev.


Проблемы и ограничения


Слишком много зависимостей


Зависимости с менеджером пакетов — это прекрасно. Это замечательный механизм для повторного использования кода и лёгкого обновления. Но вы отвечаете за то, какие зависимости и как вы включаете. Вы вносите код, который может содержать ошибки или уязвимости. Вы начинаете зависеть от того, что написано кем-то другим и чем вы можете даже не управлять. Не говоря уж о том, что вы рискуете стать жертвой сторонних проблем. Packagist и GitHub позволяют очень сильно снизить такие риски, но не избавляют от них совсем. Фиаско с left-pad в JavaScript-сообществе — хороший пример ситуации, когда всё может пойти наперекосяк, так что добавление пакетов иногда приводит к неприятным последствиям.


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


Резюме


Выбирайте зависимости с умом и старайтесь ограничить их количество.


Жёсткий конфликт


Рассмотрим пример:


{
    "require-dev": {
        "phpstan/phpstan": "^1.0@dev",
        "phpmetrics/phpmetrics": "^2.0@dev"
    }
}

Эти два пакета — инструменты статичного анализа, при совместной установке они иногда конфликтуют, поскольку могут зависеть от разных и несовместимых версий PHP-Parser.


Это вариант «глупого» конфликта: он возникает лишь тогда, когда пытаешься включить зависимость, несовместимую с твоим приложением. Пакеты не обязаны быть совместимыми, ваше приложение не использует их напрямую, к тому же они не исполняют ваш код.


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


{
    "require-dev": {
        "symfony/framework-bundle": "^4.0",
        "laravel/framework": "~5.5.0" # gentle reminder that Laravel
                                      # packages are not semver
    }
}

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


Непроверяемые зависимости


Посмотрите на этот composer.json:


{
    "require": {
        "symfony/yaml": "^2.8 || ^3.0"
    },
    "require-dev": {
        "symfony/yaml": "^3.0"
    }
}

Здесь кое-что происходит… Можно будет установить компонент Symfony YAML (пакет symfony/yaml) только версий [3.0.0, 4.0.0[.


В приложении вам наверняка не будет до этого дела. А вот в библиотеке это может привести к проблеме, потому что у вас никогда не получится протестировать свою библиотеку с symfony/yaml [2.8.0, 3.0.0[.


Станет ли это настоящей проблемой — во многом зависит от конкретной ситуации. Нужно иметь в виду, что подобное ограничение может встать поперёк, и выявить это будет не так просто. Показан простой пример, но если требование symfony/yaml: ^3.0 спрятать поглубже в дерево зависимостей, например:


{
    "require": {
        "symfony/yaml": "^2.8 || ^3.0"
    },
    "require-dev": {
        "acme/foo": "^1.0"  # requires symfony/yaml ^3.0
    }
}

вы об этом никак не узнаете, по крайней мере сейчас.


Решения


Не использовать пакеты


KISS. Всё нормально, на самом деле вам этот пакет не нужен!


PHAR’ы


PHAR’ы (PHP-архивы) — способ упаковки приложения в один файл. Подробнее можно почитать об этом в официальной PHP-документации.


Пример использования с PhpMetrics, инструментом статичного анализа:


$ wget https://url/to/download/phpmetrics/phar-file -o phpmetrics.phar
$ chmod +x phpmetrics.phar
$ mv phpmetrics.phar /usr/local/bin/phpmetrics
$ phpmetrics --version
PhpMetrics, version 1.9.0
# or if you want to keep the PHAR close and do not mind the .phar
# extension:
$ phpmetrics.phar --version
PhpMetrics, version 1.9.0

Внимание: упакованный в PHAR код не изолируется, в отличие от, например, JAR’ов в Java.


Наглядно проиллюстрируем проблему. Вы сделали консольное приложение myapp.phar, полагающееся на Symfony YAML 2.8.0, который исполняет PHP-скрипт:


$ myapp.phar myscript.php

Ваш скрипт myscript.php применяет Composer для использования Symfony YAML 4.0.0.


Что может случиться, если PHAR загружает класс Symfony YAML, например Symfony\Yaml\Yaml, а потом исполняет ваш скрипт? Он тоже использует Symfony\Yaml\Yaml, но ведь класс уже загружен! Причём загружен из пакета symfony/yaml 2.8.0, а не из 4.0.0, как нужно вашему скрипту. И если API различаются, всё ломается напрочь.


Резюме


PHAR’ы замечательно подходят для инструментов статичного анализа вроде PhpStan или PhpMetrics, но ненадёжны (как минимум сейчас), поскольку исполняют код в зависимости от коллизий зависимостей (на данный момент!).


Нужно помнить о PHAR’ах ещё кое-что:


  • Их труднее отслеживать, потому что они не поддерживаются нативно в Composer. Однако есть несколько решений вроде Composer-плагина tooly-composer-script или PhiVe, установщика PHAR’ов.
  • Управление версиями во многом зависит от проекта. В одних проектах используется команда self-update а-ля Composer с разными каналами стабильности. В других проектах предоставляется уникальная конечная точка скачивания с последним релизом. В третьих проектах используется функция GitHub-релиза с поставкой каждого релиза в виде PHAR и т. д.

Использование нескольких репозиториев


Одна из самых популярных методик. Вместо того чтобы требовать все зависимости мостов в одном файле composer.json, мы делим пакет по нескольким репозиториям.


Возьмём предыдущий пример с библиотекой. Назовём её acme/foo, затем создадим пакеты acme/foo-bundle для Symfony и acme/foo-provider для Laravel.


Обратите внимание, мы пока ещё можем всё поместить в один репозиторий, а для других пакетов вроде Symfony использовать репозитории только для чтения.


Главное преимущество этого подхода в том, что он относительно прост и не требует дополнительных инструментов, за исключением разделителя по репозиториям вроде splitsh, используемого для Symfony, Laravel и PhpBB. А недостаток в том, что теперь вместо одного пакета вам нужно поддерживать несколько.


Настройка конфигурации


Можно пойти другим путём и выбрать более продвинутый скрипт установки и тестирования. Для нашего предыдущего примера можно использовать подобное:


#!/usr/bin/env bash
# bin/tests.sh
# Test the core library
vendor/bin/phpunit --exclude-group=laravel,symfony
# Test the Symfony bridge
composer require symfony/framework-bundle:^4.0
vendor/bin/phpunit --group=symfony
composer remove symfony/framework-bundle
# Test the Laravel bridge
composer require laravel/framework:~5.5.0
vendor/bin/phpunit --group=symfony
composer remove laravel/framework

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


Использование нескольких composer.json


Этот подход довольно свежий (в PHP), в основном потому, что раньше не было нужных инструментов, так что я расскажу чуть подробнее.


Идея проста. Вместо


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0",
        "phpstan/phpstan": "^1.0@dev",
        "phpmetrics/phpmetrics": "^2.0@dev"
    }
}

мы установим phpstan/phpstan и phpmetrics/phpmetrics в разные файлы composer.json. Но тут возникает первая сложность: куда их класть? Какую создавать структуру?


Здесь поможет composer-bin-plugin. Это очень простой плагин для Composer, позволяющий взаимодействовать с composer.json в разных папках. Допустим, есть корневой файл composer.json:


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0"
    }
}

Установим плагин:


$ composer require --dev bamarni/composer-bin-plugin

После этого, если выполнить composer bin acme smth, то команда composer smth будет выполнена в поддиректории vendor-bin/acme. Теперь установим PhpStan и PhpMetrics:


$ composer bin phpstan require phpstan/phpstan:^1.0@dev
$ composer bin phpmetrics require phpmetrics/phpmetrics:^2.0@dev

Будет создана такая структура директорий:


... # projects files/directories
composer.json
composer.lock
vendor/
vendor-bin/
    phpstan/
        composer.json
        composer.lock
        vendor/
    phpmetrics/
        composer.json
        composer.lock
        vendor/

Здесь vendor-bin/phpstan/composer.json выглядит так:


{
    "require": {
        "phpstan/phpstan": "^1.0"
    }
}

А vendor-bin/phpmetrics/composer.json выглядит так:


{
    "require": {
        "phpmetrics/phpmetrics": "^2.0"
    }
}

Теперь можно использовать PhpStan и PhpMetrics, просто вызвав vendor-bin/phpstan/vendor/bin/phpstan и vendor-bin/phpmetrics/vendor/bin/phpstan.


Пойдём дальше. Возьмём пример с библиотекой с мостами для разных фреймворков:


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0",
        "symfony/framework-bundle": "^4.0",
        "laravel/framework": "~5.5.0"
    }
}

Применим тот же подход и получим файл vendor-bin/symfony/composer.json для моста Symfony:


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0",
        "symfony/framework-bundle": "^4.0"
    }
}

И файл vendor-bin/laravel/composer.json для моста Laravel:


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0",
        "laravel/framework": "~5.5.0"
    }
} 

Наш корневой файл composer.json будет выглядеть так:


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "bamarni/composer-bin-plugin": "^1.0"
        "phpunit/phpunit": "^6.0"
    }
}

Для тестирования основной библиотеки и мостов теперь нужно создать три разных PHPUnit-файла, каждый с соответствующим файлом автозагрузки (например, vendor-bin/symfony/vendor/autoload.php для моста Symfony).


Если вы попробуете сами, то заметите главный недостаток подхода: избыточность конфигурирования. Вам придётся дублировать конфигурацию корневого composer.json в другие два vendor-bin/{symfony,laravel/composer.json, настраивать разделы autoload, поскольку пути к файлам могут измениться, и когда вам потребуется новая зависимость, то придётся прописать её и в других файлах composer.json. Получается неудобно, но на помощь приходит плагин composer-inheritance-plugin.


Это маленькая обёртка вокруг composer-merge-plugin, позволяющая объединять контент vendor-bin/symfony/composer.json с корневым composer.json. Вместо


{
    "autoload": {...},
    "autoload-dev": {...},
    "require": {...},
    "require-dev": {
        "phpunit/phpunit": "^6.0",
        "symfony/framework-bundle": "^4.0"
    }
}

получится


{
    "require-dev": {
        "symfony/framework-bundle": "^4.0",
        "theofidry/composer-inheritance-plugin": "^1.0"
    }
}

Сюда будет включена оставшаяся часть конфигурации, автозагрузки и зависимостей корневого composer.json. Ничего конфигурировать не нужно, composer-inheritance-plugin — лишь тонкая обёртка вокруг composer-merge-plugin для предварительного конфигурирования, чтобы можно было использовать с composer-bin-plugin.


Если хотите, можете изучить установленные зависимости с помощью


$ composer bin symfony show

Я применял этот подход в разных проектах, например в alice, для разных инструментов вроде PhpStan или PHP-CS-Fixer и мостов для фреймворков. Другой пример — alice-data-fixtures, где используется много разных ORM-мостов для уровня хранения данных (Doctrine ORM, Doctrine ODM, Eloquent ORM и т. д.) и интеграций фреймворков.


Также я применял этот подход в нескольких частных проектах как альтернативу PHAR’ам в приложениях с разными инструментами, и он прекрасно себя зарекомендовал.


Заключение


Уверен, кто-то сочтёт некоторые методики странными или нерекомендуемыми. Я не собирался давать оценку или советовать что-то конкретное, а хотел лишь описать возможные способы управления зависимостями, их достоинства и недостатки. Выберите, что вам больше подходит, ориентируясь на свои задачи и личные предпочтения. Как кто-то сказал, не существует решений, есть только компромиссы.

Комментарии (22)


  1. Xu4
    13.12.2017 00:39

    Лично мне кажется, что copy-paste кода библиотеки лучше работает, чем включение этой библиотеки через Composer. Может быть, я неправильно что-то считаю, но у меня (пример ситуации) выходит так, что использовать код StringHelper из Yii Framework напрямую (удалив из него код, зависимый от HTMLPurifier) выходит выгоднее по времени, чем использование композера. На удаление лишних методов и зависимостей из кода я потрачу один раз несколько минут, и код раз за разом будет нормально работать. Но если я буду что-то включать через Composer, то через какое-то время получится, что на операции обновления зависимостей через него у меня затрачивается больше времени — оно [время] просто накапливается. Сегодня ушло 20 секунд на то, чтобы Composer прочекал все зависимости, завтра ушло ещё 20 секунд. Послезавтра ещё 30 (в новой версии билиотеки, в методе, который я никогда не буду использовать, появилась зависимость от другой библиотеки, которая, конечно же, загрузится и потянет за собой ещё какие-нибудь зависимости). В какой-то момент получится, что я мог потратить с самого начала 2 минуты на copy-paste и удаление ненужного кода, но, в итоге, я потратил за 2 месяца 30 минут только на ожидание, пока композер всё прочекает и обновит. (В итоге, за всю карьеру накапливается куча бесполезно проведённого времени, которое можно было бы потратить гораздо веселее — например, сметнуться в Новую Зеландию за презиками.)

    (Теги, конечно же, не читал, потому что их никто не читает)


    1. tzlom
      13.12.2017 02:11

      Ну это пока пакет не обновляется а коллеги берут код с FTP, если же вы хотите обновляемый пакет, то математика работает в обратную сторону — 10 минут ручной работы против 20 секунд композера.
      Про надёжность того что вы там на удаляли я вообще молчу — зачем перекладывать бремя тестирования пакета на себя? Да и пулл реквест сделать уже будет проще.
      Кстати, если композер у вас долго работает — возможно его стоит обновить? У меня он весьма быстр.
      Если у вас проблема с композером — обновляйтесь пока ходите за чаем.


      1. Xu4
        13.12.2017 03:06

        если же вы хотите обновляемый пакет

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

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

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

        Это вопрос надёжности ПО. У каждого свои критерии надёжности и того, сколько внимания он готов этому уделять. Есть примеры того, как откровенно вредный код может без вашего ведома подтянуться вместе с зависимостями. В общем-то, чем больше экономишь время, полагаясь на чужой код, который полагается на библиотеку, которая зависит от четвёртой библиотеки, тем выше риск получить нерабочий или уязвимый код.

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

        Я просто верю, что стремление к надёжному и безопасному коду приводит в компанию людей, которым нужен надёжный и безопасный код, и которые готовы платить больше за твою внимательность в разработке.

        Ну это пока пакет не обновляется а коллеги берут код с FTP

        Я не совсем понимаю, причём тут FTP, если подключаемый код будет лежать в том же самом репозитории VCS. Я всегда считал, что FTP вместе с мамонтами вымер.


    1. symbix
      13.12.2017 04:37

      Сегодня ушло 20 секунд на то, чтобы Composer прочекал все зависимости, завтра ушло ещё 20 секунд.

      А зачем вы вообще запускаете composer update, если вас устраивает версия, которая указана в composer.lock?


      1. Xu4
        13.12.2017 04:47

        У меня иногда бывали моменты, когда в начале проекта корневые неймспейсы часто появляются/удаляются, пока не устаканится какая-то определённая структура. Они, в общем-то, в composer.json прописываются, и чтобы autoload.php и прочие сопутствующие загрузчики перегенерировались, нужно запускать composer update


        1. symbix
          13.12.2017 06:35

          Не нужно. Достаточно запустить composer dump-autoload.


    1. Caelwyn
      13.12.2017 13:39

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


  1. VolCh
    13.12.2017 13:28

    Столкнулся давеча с таким подходом в процессе перевода приложения с 5 на 7. Не понравилось.


  1. ghost404
    14.12.2017 22:37

    Я видимо что-то не понимаю.


    Очевидно же, что в библиотеке require желательно оставить пустым, а в require-dev пишем, что хотим, так как эти пакеты используются только для разработки и тестирования и к клиенту они не попадут.


    В проекте же, в require, можно вообще любые зависимости держать. За них отвечает команда разработчиков.


    Проблема совмещения не совместимых пакетов мне вообще не понятна.
    Зачем пытаться совместить несовместимое?
    Если пакеты не совместимы, то просто не используйте их или выберете совместимую версию. В крайнем случае, всегда можно выбрать конкретный коммит в пакете. Да, выбрать конкретную версию зависимости у зависимости тоже можно.


    А если хочется использовать фичу из последней версии пакета, которая не совместима с другими пакетами, то просто не используйте эту фичу. Подождите пока исправят совместить или сделайте PR.


    Не стоит гнаться за последними релизами.


    Мне бывает приходится использовать фиксированную версию пакетов, но только потому, что разработки ломают обратную совместимость.


    Решение vendor in vendor мне видится странным и наверняка добавит проблем.


    1. VolCh
      15.12.2017 10:05

      Не всегда всё так просто. Например, всё отлично работает на PHP5.6, приходит задача перевести всё на PHP7.1 по требованию аудитора/регулятора (альтернатива — закрыть бизнес) и выясняется, что две зависимости, которые отлично работали вместе под 5.6, несовместимы теми версиями с 7.1, а новые совместимые с 7.1 версии не работают вместе. Ну и PR могут не принимать месяцами или закрыть его вовсе с wantfix


      1. oxidmod
        15.12.2017 11:26

        Всегда можно форкнуть и пофиксить проблему в своем форке.


        1. VolCh
          15.12.2017 12:47

          Вариант в целом, более того лично так делал. Но не всегда легко взять и пофиксить, даже если в новой версии проблемы нет. очень сильно вникать в код библиотеки может понадобиться, особенно если это не лефтпад какой-нибудь, а ОРМ, например, или поддержка какого-то зубодробительного формата типа xls


          1. oxidmod
            15.12.2017 12:54

            Ну и как вам поможет копипаста либы, если либа сложная и багу фиксить всеравно надо?


            1. VolCh
              15.12.2017 12:57

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


      1. ghost404
        15.12.2017 20:23

        Я сейчас перевожу один проект на PHP 7. Одн из пакетов оказался не совместим с PHP 7.
        Так как проект заброшен, я просто взял и заменил его на другой. Переписал пару адаптеров и все.
        Ну ещё немножко смазал маслицем чтоб лучше зашёл


    1. pbatanov
      15.12.2017 21:27

      Очевидно же, что в библиотеке require желательно оставить пустым, а в require-dev пишем, что хотим, так как эти пакеты используются только для разработки и тестирования и к клиенту они не попадут.


      я правильно понимаю, что вы заставляете пользователя искать транзитивные зависимости вручную? ну типа у вас есть какой-нибудь api клиент с использованием guzzle, а guzzle нужной версии вы заставляете людей ставить?

      А если ваша библиотека написана с использованием nullable, а у человека 7.0, он тоже об этом только опытным путем узнает?


      1. oxidmod
        15.12.2017 21:37

        Требования платформы естественно указываются.
        Либа зависит от пср-овского хттп клиента. В suggest-ах может предложить что-то. Но зачем тащить guzzle, если приложение уже что-то использует? Пользователь вашей либы просто наконфигурит какой клиент юзать.


        1. pbatanov
          15.12.2017 22:04
          +1

          в таком случае
          1. у вас в src нигде не будет зависимости от guzzle, только от psr
          2. в require у вас будет какой нибудь psr/http-message все равно, т.к он требуется для вашей библиотеки


        1. pbatanov
          15.12.2017 22:38

          C газлом возможно неудачный пример, надо было брать какой-нибудь соап клиент, на которых нормальных стандартов не понаписали, а наследоваться от \SoapClient все не хотят


          1. oxidmod
            16.12.2017 00:08

            Можно разделить либу на 2 пакета. В первом объявить интерфейс, который должен быть реализован (аналог пср-а) и завязать всю логику на интрфейс.
            Во втором пакете можно предоставить реализацию, которая с требованиями к платформе и потенциально конфликтующей реализацией.

            Второй пакет в саггестах у первого.
            Нету конфликтов и лень чето выдумывать? Ставь пакет из саггеста.
            Не устраивает пакет из саггеста? Напили свою реализацию или найди подходящую и сделай адаптер.


            1. ghost404
              16.12.2017 00:59

              Обычно я так и делаю. Один пакет чистый без зависимостей, а второй bundle для Symfony с конфигами и всеми делами.
              Хочешь юзать в Symfony ставь бандл. Если у тебя не Symfony, юзай чистый пакет.


            1. pbatanov
              16.12.2017 18:30

              саджест все равно в таких случаях некорректен. саджест — это предложение, а в вашем случае без установки такого «предложения» пакет не будет работать. в таких случаях делают по-другому:

              реквестят (мета)-пакет «some-vendor/my-super-interface-implementation»
              этот пакет не зарегистрирован в packagist, но любой другой пакет (например из вашего списка саджестов) может прописать у себя provides some-vendor/my-super-interface-implementation

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

              Вот например так работают с тем же PSR-3 логгером

              packagist.org/providers/psr/log-implementation