Как стать автором
Обновить

Управление зависимостями в PHP

Блог компании Mail.ru Group Разработка веб-сайтов *PHP *Проектирование и рефакторинг *Отладка *
Перевод
Автор оригинала: Théo Fidry

При создании 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’ам в приложениях с разными инструментами, и он прекрасно себя зарекомендовал.


Заключение


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

Теги: phpcomposerpharникто не читает теги
Хабы: Блог компании Mail.ru Group Разработка веб-сайтов PHP Проектирование и рефакторинг Отладка
Всего голосов 36: ↑34 и ↓2 +32
Комментарии 50
Комментарии Комментарии 50

Похожие публикации

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
team.mail.ru
Численность
5 001–10 000 человек
Дата регистрации
Представитель
Павел Круглов

Блог на Хабре