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

в 13:42, , рубрики: composer, phar, php, Блог компании Mail.Ru Group, никто не читает теги, отладка, Проектирование и рефакторинг, Разработка веб-сайтов
Управление зависимостями в PHP - 1

При создании 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

Источник

Поделиться

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