PHP Profi

Управление зависимостями и решение конфликтов в PHP через Composer Перевод

Когда вы создаете PHP-приложение или библиотеку, вы, как правило, имеете 3 вида зависимостей:

  • Жесткие зависимости: то, что требуется вашему приложению/библиотеке, чтобы работать
  • Необязательные зависимости: например, ваша 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 , который имеет некоторые ограничения.

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

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

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

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

TL:DR; Следите за тем, какие зависимости вы подключаете и стремитесь к наименьшему их числу.

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

Давайте рассмотрим следующий пример:

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

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

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

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

{
    "require-dev": {
        "symfony/framework-bundle": "^4.0",
        "laravel/framework": "~5.5.0" # небольшое напоминание о том, что
                                      # пакеты Laravel не следуют 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"  # требует 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

Предупреждение: упаковка кода в 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 отличается, это всё поломается.

TL:DR; PHAR`ы отлично подходят для инструментов на подобие статических анализаторов, как PhpStan или PhpMetrics, но ненадежны (пока по крайней мере), если они выполняют код, приводящий к коллизиям зависимостей.

Есть также несколько других вещей, которые стоит иметь в виду при использовании PHAR`ов:

  • За ними сложнее следить, т.к. для них нет родной поддержки в Composer. Однако существует несколько решений - таких, как плагин для Composer`а tooly-composer-script или PhiVe.
  • Как происходит управление версиями во многом зависит от проекта. Некоторые проекты, à la Composer, предлагают команду self-update  с каналами различной стабильности, некоторые предоставляют уникальный урл для скачивания последней версии, некоторые используют возможности 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

Это создаст следующую структуру каталогов:

... # (файлы и директории проекта)
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"
    }
}

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

Если вы на самом деле попробуете это сделать, вы заметите существенный недостаток в таком подходе: избыточная конфигурация. Действительно, нужно дублировать конфиг корневого composer.json`а на два других (vendor-bin/{symfony,laravel}/composer.json), настроить разделы автозагрузки, поскольку путь к файлам изменился, и всякий раз, когда вы подключаете новую зависимость, вы также должны её подключить в других 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. Теперь не осталось ничего, что нужно настраивать.

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

$ composer bin symfony show

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

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

Заключение

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


Если вы сталкивались с подобными проблемами, расскажите в комментариях об опыте решения проблем с зависимостями. Какой способ вы использользовали? На какие подводные камни наткнулись и как их обходили? Как вы считаете на сколько применимы такие подходы и стоит ли ими пользоваться или только некоторыми?

Кто-нибудь использовал плагины  composer-bin-plugin / composer-inheritance-plugin / composer-merge-plugin?

2017-12-20 оригинал

Последние посты

Комментарии

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