При создании 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, например SymfonyYamlYaml
, а потом исполняет ваш скрипт? Он тоже использует SymfonyYamlYaml
, но ведь класс уже загружен! Причём загружен из пакета 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’ам в приложениях с разными инструментами, и он прекрасно себя зарекомендовал.
Заключение
Уверен, кто-то сочтёт некоторые методики странными или нерекомендуемыми. Я не собирался давать оценку или советовать что-то конкретное, а хотел лишь описать возможные способы управления зависимостями, их достоинства и недостатки. Выберите, что вам больше подходит, ориентируясь на свои задачи и личные предпочтения. Как кто-то сказал, не существует решений, есть только компромиссы.
Автор: AloneCoder