Состоялась публикация PHP библиотеки для сокращения текста в HTML коде до требуемого количества символов. Такая функциональность бывает необходима для разных целей — для создания пред-просмотра результата поиска, для сокращения ссылок, или текста элементов в списке — как в моём случае — для сокращения текста статей в разделе их списка в личном блоге.
Реализации решения этой задачи, конечно, существуют — однако, при их анализе установлено, что каждая из них фактически не поддерживается в течение долгого времени и устарела, с точки зрения современных инструментов.
Изначально при создании проекта блога, требующего такую функциональность, было выбрано уже существующее решение, но со временем стало понятно, что в нём есть дефекты, например:
- некорректная вставка закрывающего тега (см. !9)
- формирование кода полноценного документа, встраивая на основную страницу которого нарушает семантику основного, что ведёт к сопутствующим проблемам, включая некорректную индексацию страниц поисковыми системами (см. !6)
Со стороны сообщества были попытки их исправить, однако эти предложения были оставлены без ответа.
В связи с этими обстоятельствами было принято решение реализовать новый инструмент. Помимо базовых требований к функционалу, таких как:
- сохранение метаинформации в теле документа
- рекурсивное удаление смежных элементов относительно элемента, содержащего символ, ограниченный максимальной длиной текста
и других, были предъявлены дополнительные:
- использование стандартного API для работы с DOM (libxml)
- отсутствие рекурсии при обходе древа элементов
- возможность поддержки документов большого объёма
- соответствие принципам SOLID и стандартам PSR
- использование инструментов анализа качества решений
Реализация собственного решения задачи не имеет сложность, стоящая освещения в виде статьи, однако аналитика, проведённая в процессе работы — может оказаться интересной.
Выбор версии PHP для поддержки библиотекой
В 2026 году основной версией PHP является PHP8. Последняя версия PHP5 не получает обновления безопасности с 2019 года, а последняя версия PHP7 — с 2023 года.
При выборе версий следует понимать историю изменений и особенности процесса поддержки PHP, а также связь версий библиотеки с версией PHP.
Сегодня в мире PHP основной инструмент для распространения и установки библиотек — инструмент Composer, являясь стандартом де-факто, как и семантическое версионирование, используемое для библиотек.
Версионирование PHP не руководствуется стандартом семантического версионирования и, формально, повышение старшей версии PHP в проекте не обязывает к повышению старшей версии библиотеки, так как такое изменение может не нарушать обратную совместимость.
Таким образом, изменение младшего разряда версии PHP — может привести к нарушению обратной совместимости, а обновление старшего разряда версии PHP — может не привести.
Метод миграции не может быть универсальным и требует разносторонний анализ. Это одна из главных причин сложности процесса миграции, особенно для больших проектов, вдобавок обложенных внутренними организационными процессами. То есть де-юре срок поддержки инструмента или библиотеки мог давно истечь, но де-факто продолжает использоваться.
В силу простоты реализации, одновременная поддержка версий PHP7 и PHP8 не оказалась сложной и не требует разделения на разные ветки версий библиотеки, обеспечивая восходящую совместимость для проектов, пока не обновлённых до актуальной версии PHP.
Последующие старшие версии библиотеки отключают одновременную поддержку старых версий PHP, оставляя соответствующие x-branch для случаев внесения правок возможных проблем.
Включение инструмента в качестве зависимости проекта
Статические анализаторы кода Psalm, PHPStan и Deptrac справляются со своей задачей отлично, однако могут создавать проблемы при разрешении версий зависимостей в проекте.
Последние версии библиотек (использующие последние версии PHP) могут взаимодействовать с кодом более старых версий PHP — обычно согласование производится по версии, указанной в файле composer.json, либо в автоматическом режиме.
Включение библиотеки в проект в качестве зависимости имеет смысл,
если пользовательский код использует API этого компонента, или дополняет его функциональность —
например, для PHPStan это может быть семантическое правило, типа
ограничения использования control-flow операторов в блоке finally,
а для PHPUnit — это функциональность базовых тест-кейсов и мокирования.
В случае с PHPStan практика использования API библиотеки обычно редкая, особенно в небольших проектах и в действительности редко обязывает включать инструмент. А в случае с PHPUnit — включение этой библиотеки в проект обязательно, поскольку пользовательский код использует его функциональность.
Обобщая — решение включения инструмента в зависимости библиотеки зависит от фактора зацепления библиотеки от этого инструмента.
Формально, задачу включения в проект решить можно, однако поддерживая разные версии PHP по старшим разрядам приведёт к очень большой разнице версий инструментов, что потребует поддержку совместимости на уровне пользовательского кода, что, может быть, и возможно, но попросту нецелесообразно. Возможно, следует разделять поддержку по разным x-branch или отказываться от старых версий PHP.
Разные версии — разные зависимости и конфигурации
Анализаторы статического кода могут использовать исходный код внешних зависимостей, поэтому перед их вызовом следует установить зависимости согласно окружению приложения, имея в виду PHP соответствующей версии.
Аналогичная задача возникла в ходе решения предыдущей — для Docker-образа PHP7.1 — ОС, используемая в качестве основы, устарела и сегодня не поддерживается. Потребовалось переключить список репозиториев на архивные для установки необходимых зависимостей. И вспоминая вопрос выбора минимальной версии PHP, если бы обеспечивалась поддержка PHP5 — пришлось бы решать проблему поддержки старой версии манифеста Docker или собирать новый образ с нуля.
Разделив использование инструментов на "в проекте" и "из вне проекта", встала задача поддержки последних версий для соответствующих версий PHP. Для разных версий PHPUnit используются разные XSD схемы файла конфигураций, следовательно, требуется поддержка каждой из них. В таком случае требуется для каждой версии PHPUnit указывать файл, использующую поддерживаемую XSD схему.
Одним из нововведений PHP8 стал функционал аттрибутов и PHPUnit
перевёл в первой же версии аннотации @dataProvider на аттрибут #[\PHPUnit\Framework\Attributes\DataProvider]
при требовании PHP >= 8. Таким образом, в коде потребовалось поддерживать и аттрибут, и аннотацию.
Более новый PHPUnit игнорирует аннотацию, а более старый — так как PHP7,
на котором он работает — считает это комментарием — нет. И при этом, импорт несуществующего
класса (\PHPUnit\Framework\Attributes\DataProvider) не вызывает ошибку, так как он не используется.
Один аттрибут и нужен polyfill
Для поддержки последней версии PHP и соблюдая строгие требования статических анализаторов,
в PHP >= 8.3 для переопределённых элементов в классе следует использовать аттрибут [\Override].
Для решения задачи поддержки PHP версий 8.0+ и 8.3+ потребовался функционал наполнителя (polyfill).
Библиотека symfony/polyfill-php83 ушла дальше поддержки PHP7.1 в качестве минимальной,
поэтому для её использования пришлось выбрать её более раннюю версию.
Задача polyfill'а (добавлять недостающее) автоматически исключает конфликт его кодовых сущностей и инструмента, таким образом, зависимость polyfill'а для версий PHP >= 8.3 становится просто пустышкой и избавляет от необходимости решения включения библиотеки в зависимости от версии PHP.
Конечно, можно было бы избавиться от использования функционала dataProvider в пользу независимых тестов,
но, устанавливая акцент в балансе качество/скорость на качество, стоит адаптировать среду под задачи, а не наоборот.
Переносимость и принцип DRY
Определив требования к качеству — получился следующий набор инструментов:
- PHPStan — статический анализатор кода
- Psalm — статический анализатор кода
- PHPUnit — тестирование функциональности и оценка покрытия реализации тестами
- Deptrac — статический анализатор архитектурных правил
Имея в виду, что проект подразумевает использование CI/CD для автоматизации процесса анализа и публикации библиотеки — требуется соответствующая конфигурация и возможность выполнять такой же сценарий локально при разработке.
С одной стороны, на практике проекты допускают дублирование сценариев для локальной и CI сред, подразумевая обязательство поддержки согласованности логики. С другой стороны, существуют инструменты для запуска Workfklow локально, например:
- nektos/act для GitHub
- firecow/gitlab-ci-local или gitlab-runner для GitLab
В таком случае следует учитывать следующие риски:
- Необходимость в разграничении по контуру: выделение конфигурации для контуров локального и production окружений, хотя по дизайну конфигурация предназначается только для инструмента CI/CD (например GitHub или GitLab). Для примера: разрешать выполнение шагов тестирования, проверки качества и безопасности продукта — но запрещать сборку и публикацию.
Реализация процессов жизненного цикла смещается в конфигурацию workflow.
Разные конфигурации окружения предполагают использование механизма матрицы каждая задача из матрицы обычно выполняется в изолированном окружении (то есть почему почти в каждой задаче следует выполнять checkout action). Если на серверах GitHub это способствует ускорению выполнения задач без ощущения нагрузки на железо, локально это может быть ощутимо. По умолчанию, локальный запуск workflow не разделяет среду, а это приводит к проблеме состояния гонки (race condition), что всегда ведёт к завершению выполнения ошибкой.
С одной стороны можно избежать дублирования кода и сопровождения согласованности, подключая дополнительный инструмент — с другой стороны добавляется сложность его внедрения и поддержки.
Итог
Итоговая структура задач получилась следующей:
- Deptrac
- (для каждой версии php: 71, 80, 85)
- Composer dependencies install
- Composer audit
- Symfony vulnerability check
- (для каждой версии php: 71, 80, 85)
- Composer dependencies install
- Psalm
- PHPStan
- (для каждой версии php: 71, 80, 85)
- Composer dependencies install
- PHPUnit
На ум приходит следующие выражения:
- > Чем дальше в лес — тем больше дров
- > Нет предела совершенству
- > Лучшее, враг хорошего
и вспоминается комикс xkcd №676:
Помимо полученной пользы, в виде качественного решения, работа над проектом добавила новый примечательный опыт:
- первая публикация библиотеки в Packagist
- первый PHP проект с настроенными требованиями максимальной строгости
И хотя эксперимент в целом получился положительным, по результату остались пока нерешённые задачи, например:
- В каждой задаче CI выполнятся сборка Docker образа приложения и время выполнения всего workflow занимает порядка 2-3 минут. Требуется настроить переиспользование образа.
- Разница значений параметров запуска задач локально и в CI. За счёт изоляции runner'ов в GitHub пропадает необходимость в управлении версиями среды исполнения, локально следует её сохранить.
- Идентичность изоляции runner'ов в GitHub vs. nektos/act. По умолчанию, локальный запуск workflow не разделяет среду, а это приводит к проблеме состояния гонки (race condition), что всегда ведёт к завершению выполнения ошибкой.
Вместе с публикацией основной библиотеки md-php/html-truncation опубликована вспомогательная —
md-php/bridge-twig-html-truncation предназначенная для интеграции первой c компонентом Twig
и позволяющая вызывать функции основной библиотеки прямо из шаблона Twig.
Исходный код реализации доступен на GitHub: https://github.com/md-php/html-truncation/