В эти дни проходит внутренняя конференция Aventus IT. И я там был, мёд пиво пил… Шучу. Выступал на тему автоматизации контроля качества кода. И хочу представить вам статью по материалам своего выступления.
Сегодня было два прекрасных доклада про тесты на проекте. Порадовало, что услышал в них некоторые свои идеи. Например, про преимущества запуска тестов на CI вместо локальной машины. Оказывается, что я не один такой.
Со своей стороны, хочу поговорить, про контроль качества кода помимо тестов. Постарался сделать доклад максимально практичным. Впрочем, не без необходимой лирики. В статье начну с каких-то может быть банальных вещей, от которых мы перейдём к менее очевидным и, вместе с тем, важным вещам.
Мотивация автоматизации
Для начала разберемся для чего это нужно.
У программного продукта есть заказчик, поэтому в первую очередь ответим, что нужно бизнесу: бизнесу необходима система выполняющая бизнес задачи. И одно из требований бизнеса ー эта система должна работать стабильно. Бизнесу по сути всё равно сколько костылей мы туда вставили. Ему не до красоты кода. Главное, что его интересует, ー система должна работать… И при минимально возможных издержках. И вот тут у нас есть точки соприкосновения.
Как мы можем обеспечить качество программного продукта? Всё начинается с ручного контроля:
Code review
Manual QA
Это требует много времени. Соответствующих зарплат. Так Code Review в идеале должен проводить более сильный специалист, чем тот кто писал код. Если мы не говорим про обучающую функцию cross-review, то основная задача ревью ー это найти ошибки и слабые стороны в предложенном решении. То же касается и QA. Они неоправданно недооценены. В идеале, QA специалист должен понимать логику работы системы лучше, чем разработчик! Ведь он контролирует разработчика. На это нужна соответствующая квалификация. И зарплата. Это дорого!
Что будет если автоматизировать проверку кода. И сейчас речь не про тесты, которые живые и которые постоянно должны меняться вместе с изменением проекта. Существует множество вещей, которые нужно один раз настроить и далее всё время получать профит. Остаётся только оплатить серверное время для раннера на CI. Сколько стоит месяц машинного времени, выделенное под runner на CI на проекте? 50-100-200-300-500 долларов? Тут команда саппорта может дать более точные цифры. Конечно это не все затраты, тем не менее, это сильно дешевле, чем время 2-3-х сотрудников (QA + dev). Возьмём оценочно 5000$ на всех участников процесса.
Теперь возьмите разницу этих цифр и умножьте на 12 месяцев и хотя бы на 2-3 года. И посчитайте на 10 лет. Многие проекты живут ещё дольше. Цифра впечатляет.
Что настраиваем
Будучи лидом, в первую очередь, я начал экономить своё время на код ревью. И поставил соответствующие скрипты у себя на проекте.
PHP CS Fixer (проверка стиля кода)
Начинаем с проверки стиля кода.
Почему это важно? Хоть код мы пишем условно один раз, но читаем код много-много раз. И чем быстрее разработчик разберется в чужом коде, тем быстрее он выполнит свою задачу. Для этого код разных разработчиков должен выглядеть максимально одинаковым.
Есть мнение:
Поддерживаемость кода, в том числе читабельность, важнее оптимальности алгоритмов!
Во-первых, прошли времена мэйнфреймов, когда докупить железа было сильно дороже, чем нанять команду разработчиков, пишущих машинный код.
Во-вторых, большая часть любого проекта не является высоконагруженной. И если мы столкнемся с проблемой нагрузки, то всегда можно докинуть железа и после ТОЧЕЧНО оптимизировать производительность. Так общая стоимость создания и владения системой будет ниже.
Тут важно придерживаться стандартов. Проект на Symfony ー значит включаем правила, принятые в компонентах Symfony. Так сразу снимается вопрос “а мне кажется, что так красивее”. И когда все проекты придерживаются стандартного подхода, то снижается порог входа новых разработчиков в проект.
Как упростить внедрение в проект?
Посмотреть какие ошибки встречаются не часто и исправить. Фиксер умеет делать это автоматически.
Временно отключить часть правил, которые исправить сразу сложно.
Использовать baseline. Из коробки фиксер такое не умеет, поэтому я создал пакет, который обеспечивает такую функциональность: https://github.com/Aeliot-Tm/php-cs-fixer-baseline. Он позволяет мягко отключить проверку части файлов до тех пор, пока они не будут модифицированы. Это особенно полезно на проектах с большой кодовой базой. Дополнительно, получаем ускорение проверки на CI за счёт того, что проверяются только новые файлы и модифицированные.
Code sniffer
squizlabs/php_codesniffer
Альтернатива предыдущему скрипту проверки стилей. Используем на проекте, т.к. Есть полезные правила, которых нет в фиксире. Нарпимер, длина строки.
PHPStan (статический анализ кода)
Он ищет в коде ошибки в логике. Например, несоответствие типов данных в операциях. Например, когда мы сравниваем холодное с мягким)) Null-pointer и многое другое.
Важный параметр: “level” от 1 до 8 ー это что-то типа набора пресетов правил. Чем больше цифра ー тем больше правил включено.
Имеет множество плагинов. Мы используем:
phpstan/phpstan-doctrine
- проверяет DQL, определяет возвращаемые типы магических методов репозиториев и др.phpstan/phpstan-symfony
- позволяет правильно определять типы возвращаемые из контейнера и не только.phpstan/phpstan-phpunit
- улучшает проверку моков.
Их можно подключать как вручную в основном конфиге, так и с помощью phpstan/extension-installer
.
Есть ещё тонкость с их настройкой.
Для phpstan/phpstan-doctrine необходимо предоставить файл, возвращающий ObjectManager.
doctrine:
objectManagerLoader: ../../tests/object-manager.php
allowNullablePropertyForRequiredField: true
Для phpstan/phpstan-symfony необходим загрузчик приложения и путь к xml файлу кэша контейнера.
symfony:
console_application_loader: ../../tests/console-application.php
container_xml_path: ../../var/cache/test/App_KernelTestDebugContainer.xml
Линтер YAML файлов
На проекте много YAML файлов. Так что без линтера никуда. Мы используем консольную утилиту yamllint. И чтобы не тратить время на её установку мы собрали свой docker-контейнер.
Линтер TWIG файлов
Если что-либо рендерите из twig файлов, то не забываем проверить их синтаксис линтером:
bin/console lint:twig
Проверка консистентности файлов переводов
Используется мультиязычность? Как проверить?
Есть стандартная команда Symfony, которая закрывает часть потребностей:
bin/console debug:translation --all uk
Но она может не всё. Например, не сравнивает несколько языков между собой в поисках пропущенных значений. Кроме того, на более старых версиях Symfony она была совершенно не пригодна для CI, т.к. не отдавала кодов ошибок при их обнаружении.
Если переводы в YAML файлах, а как правило именно в них, то ставим пакет https://github.com/Aeliot-Tm/translation-maintain, и радуемся жизни. Сам когда-то делал его. И настраиваем команду на CI:
bin/console aeliot_trans_maintain:lint:yaml base file_transformed
Она имеет разные готовые правила и их наборы. Дополнительной плюшкой будет то, что пакет предоставляет ряд дополнительных возможностей, по управлению файлами переводов на этапе разработки. Включая нормализацию структуры файлов и автоматический перевод, пропущенных значений на нужный язык. Подробнее можно почитать в статье на хабре: https://habr.com/ru/articles/555954/
Линтер контейнера
В большинстве Symfony проектов конфигурационные файлы в YAML формате. Линтер YAML файлов подсветит, только синтаксические ошибки. Чтобы проверить логические ошибки в определении параметров и сервисов нужен линтер контейнера. И он есть в Symfony из коробки:
bin/console lint:container
Проверка схемы базы данных
В проектах на Symfony структура базы данных, как правило, описывается с помощью аттрибутов/аннонаций. Дальше для переноса изменений структуры создаются миграции. К сожалению, не все разработчики на Symfony создают миграции с помощью этих команд:
bin/console doctrine:migrations:diff -n
bin/console make:migration -n
Можно встретить использование doctrine:migrations:generate
и ручное добавление инструкций SQL. Или разработчик может забыть создать миграцию или не сохранить её. Или поработать в одной ветке, выполнить миграции. После переключиться в другую и там создать новую. При этом забыв откатить предыдущую миграцию. После этого он пойдёт удалять лишние инструкции из новой миграции и пожет удалить что-то нужное.
Со временем у вас почти наверняка появится рассинхрон между структурой базы данных, описанной в энтитях и реальной структурой базы данных. Как не пропустить это на прод? Как вы уже поняли, настраиваем команду на CI:
bin/console doctrine:schema:validate -n -v
Здесь два аргумента отвечают за вывод более подробной информации об ошибке и отключение интерактивного режима для корректной работы на CI.
Что если у вас в базе данных есть таблицы, неуправляемые доктриной? Нужно настроить фильтрацию и дальше Доктрина сделает всё, что нужно!
PHPUnit
Как же без него. Не буду глубоко погружаться. Отмечу только несколько моментов:
Во-первых, final
классы можно прекрасно мокать. Нужен только пакет dg/bypass-finals
. Дальше одна строчка в bootstrap файле и полетели.
BypassFinals::enable();
Спорить здесь о нужности или ненужности инструкции final сейчас будем. Как и обсуждать создание one-used интерфейсов только ради тестов (читай тестового кода в продовом). Просто примите тот факт, что мокать их можно.
Не бесплатно, конечно. PHPStorm будет ругаться и PHPStan будет недовольным (baseline в помощь), но работать будет.
Во-вторых, оборачиваем все операции в базе данных в транзакции и откатываем изменения автоматически с помощью dama/doctrine-test-bundle
. Просто установите пакет и добавьте экстеншен в конфиги. И вам не нужно будет следить за этим на сетапе тестов и tearDown.
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
В-третьих, если есть желание поиграть в контроль покрытия тестами, то понадобится дополнительный пакет https://github.com/Aeliot-Tm/phpunit-codecoverage-baseline, т.к. PHPUnit из коробки умеет посчитать покрытие тестами, но не умеет сравнивать предыдущее состояние и новое.
Проверка composer-файлов
Помним про самоконтроль. Собственный файлы композера тоже следует проверять, т.к. разработчики тоже люди и могут забыть закомментировать часть изменений. Собственная команда композера поможет проконтролировать это:
composer validate --strict
Следим за неиспользуемыми зависимостями
Я придерживаюсь политики, что неработающий код нужно удалять. От источник багов и на чтение его впустую тратится время. То же самое касается и пакетов (библиотек). В процессе жизненного цикла проекта мы можем начать использовать какие-то библиотеки и потом прекратить их использование. И не всегда разработчик может проконтролировать и вовремя удалить зависимость в файлах композера. Поэтому, устанавливаем пакет icanhazstring/composer-unused
. Он позволяет контролировать не только установленные пакеты, но и требования наличию в системе тех или иных модулей PHP.
Явное добавление зависимостей
Чтобы не попасть в просак и не лишиться используемого компонента при обновлении пакетов. Устанавливаем пакет https://github.com/maglnet/ComposerRequireChecker
Если этот пакет не стоит и не настроен, то любое обновление пакетов - это как ходить босиком по минному полю с ружьём, приставленным к виску. При поиске решения для бизнес задачи разработчик может взять установленный пакет, но установленный как неявная зависимость. И после обновления этот пакет может пропасть из системы. И хорошо, если проект сразу сломается. А что если зависимость использовалась в части проекта не покрытого тестами? Тут радостные баги кричат: “Пользователи, мы идём к вам” ?
Security vulnerabilities check of composer
Ещё на этапе установки необходимо проверять пакеты на наличие известных уязвимостей. Для этого устанавливаем пакет в dev-зависимости: roave/security-advisories
Но этого недостаточно. Нужно проверяем используемые зависимости на наличие известных уязвимостей уже после установки. Для этого устанавливаем пакет: enlightn/security-checker
И настраиваем скрипт:
vendor/bin/security-checker security:check composer.lock
Scan container on vulnerabilities
Проверяем безопасность компонентов Docker контейнера с помощью утилиты trivy. Делаем это при сборке базового основного. И далее по расписанию или дополнительно в основном проекте, т.к. От всегда перед глазами.
https://github.com/aquasecurity/trivy
Создание тикетов по TODO-комментариям в коде
TODO-шки в коде отличный инструмент, но без должного контроля они превращаются в мусор. Чтобы этого избежать на некоторых проектах просто запрещают их использование.
Обычно как? Разработчик добавил TODO в коде и только он знает о ней. Или того хуже, когда на проекте запрещают добавлять такие комментарии с словами или исправь сразу или создай нормальный тикет. Тикет создавать долго. Поэтому разработчик ничего не делает с этим.
И потом Лид приходит к бизнесу и просит выделить время на исправление какого-то эфемерного тех. долга. И на вопрос “сколько его там” никто не может ответить, поскольку его никто не считал.
И снова мой пакет: https://github.com/Aeliot-Tm/todo-registrar
Так по каждой проблеме появляется тикет в JIRA (или Github issues) и попадает на оценку на груминге. И дальше с ним происходит магия менеджмента. Это сильно упрощает управление тех. долгом.
Есть статья на хабре, где всё подробно описано https://habr.com/ru/articles/832994/
Следим за удалением устаревших TODO-шек
Ставим пакет: https://github.com/staabm/phpstan-todo-by
И настраиваем отдельный запуск PHPStan для него, чтобы это джоба контролировала изолированный набор правил.
Автоматизация контроля архитектурных решений
Есть два пути:
Кастомные правила для PHPStan
Установка пакета PHPAT Который по сути является надстройкой над PHPStan и предоставляет упрощенный интерфейс для настройки собственных правил.
Итоги раздела
И это ещё не всё, что можно настроить на CI! Существует мнение:
Качество кода проекта можно в каком-то приближении оценить по числу автоматически проверяемых правил прежде чем код разработчика попадет в основную кодовую базу.
Хорошие практики в настройке скриптов
Создаём единую точку настройки для dev-tools
В идеале на локальной машине скрипты должны запускаться так же как и на CI. Можно дать инструкцию, но длинная команда с множеством параметров может быть неудобной для локального использования. К тому же, в процессе жизненного цикла разработки приложения может понадобиться скорректировать настройки скриптов, что-то куда-то перенести. Прибавьте сюда работу в разных версиях (ветках). И тут напрашивается создание единой точки входа. После ряда экспериментов выбор пал на composer-скрипты. Так мы имеем единую точку входа и для CI и для локальной разработки. С неизменным перечнем команд в разных версиях кода. + Make файл для упрощения вызова извне Docker контейнера.
Так, например, когда число конфигов dev-скриптов в корне проекта стало превышать разумные разделы и мы стали переносить их в отдельную папку. Для разработчиков ничего не поменялось. Потом мы стали переходит на использование PHAR файлов, для установки тех же скриптов вместо композера. И тоже для разработчика ничего не поменялось. Он как вызывал команду composer cs-fix, так и продолжил. Вне зависимости в какой ветке он сейчас находится.
Выносим конфиги для dev-скриптов в отдельную папку
Большинство скриптов по умолчанию рассчитывают на наличие конфига в корне проекта. Это позволяет упростить вызов скрипта и не передавать путь к нему. Со временем конфигов становится много. Особенно, когда приходится делить скрипты на несколько вызовов с разными настройками для ускорения пайплайнов. У нас в проекте это папка scripts с подпапками внутри.
Используем “наследование” в настройке джоб
Синтаксис YAML файла, поддерживаемый Gitlab CI позволяет вынести общие свойства джоб в отдельный блок и наследоваться от него. Это сильно улучшает читабельность кода и снижает нагрузку на его поддержку из-за уменьшения копипасты. После этого добавление новой джобы превращается в пару строк:
'Composer check obvious requiring of packages':
<<: *run_tests
script:
- composer require-check
'Composer check unused dependencies':
<<: *run_tests
script:
- composer unused -- --output-format=gitlab
'Doctrine database Schema Validation':
<<: *run_tests_integration
script:
- composer db-validate
Здесь же видно, что в CI мы используем ту же единую точку для скриптов. В итоге, любые изменения и перенастройки этих скриптов не вынуждают нас идти перенастраивать джобы гитлаба.
Снижаем dependency-hell с помощью PHAR/Phive
Каждый dev-script может привнести свои зависимости. Чем больше зависимостей в composer.json файле, тем труднее обновлять их до новых версий. В итоге, это может приводить к невозможности установки тех или иных компонентов. Эта проблема решается с помощью установки dev-скриптов в виде PHAR файлов. Проще всего это сделать с помощью Phive. Это что-то типа composer только для установки PHAR-файлов.
Важное замечание! По умолчанию Phive устанавливает пакеты в проект в виде ссылок (symlink) и без расширения .phar. В этом случае PHPStorm не понимает какое содержимое у этого файла и не работает подсветка синтаксиса в конфигах скриптов. Поэтому, я рекомендую всегда исправлять параметры установки пакетов в phive.xml файле.
Скорость прохождения пайплайнов на CI
Очень важно как быстро отрабатывают ваши пайплайна. Как долго они могут выполняться и какое время считается нормальным?
Встречал в литературе статистический анализ говорящий, что допустимым является 15 минут на выполнение пайплайна. Я бы сказал, что допустимым является не более полутора минут. И у меня есть аргументы:
Во-первых, горящий прод. Бизнес не любит такие слова. Наша же задача как инженеров спокойно учитывать все риски. С настроенными автоматическими проверками не получится “пропихнуть” какой-то код в прод до окончания проверок. Да и это опасно. И чем дольше проходят пайплайны, тем дольше простой в случае факапа. Мы же хотим снизить такие риски.
Во-вторых, хватит мучать разработчиков локальной прогонкой проверок! Объективно, не у всех ребят очень мощные компьютеры. И не всегда имеется возможность предоставления соответствующего оборудования. Прогонка всех проверок локально может проходить долго. И в это время разработчик не может ничего делать. Когда проверки на CI проходят быстро, то становится удобнее проверять код на сервере. Это быстро. К тому же, запушил код и сразу можно заняться чем-то ещё полезным. Имеем выигрыш в 10-30 минут на каждом пуше. И за год набегает солидный запас. Плюс, имеем хорошую привычку всегда пушить на сервер. Что дает подстраховку сохранности кода и оперативный доступа к нему всей команды.
В-третьих, конкуренция за ресурсы. Чем больше разработчиков одновременно пушат код в репозиторий, тем дольше придётся ждать, пока освободятся ресурсы раннера на запуск именно твоих тестов. Это важно при массовом пуше разработчиков. Если Ваш ранер отрабоатывает 30 параллельных джоб. И в одном пайплайне их 10. То одстаточно 3-м разработчика запушить код, и все остальные будут здать своей очереди. В итоге, 15 минут на пайплай превращаются в 30 или 45 минут. Если же ваш майплай выполняется за минуту, то если он будет выполняться 2-3 минуты, то это никто особо не почувствует.
В-четвертых, снижение затрат. Чем быстрее проходят проверки, тем меньше нужно выделять ресурсов для раннеров. И меньше простоев разработчиков из-за ожидания завершения автоматических проверок.
Оптимизируем сборку контейнера
Первое, что нужно сделать ー оптимизировать сборку контейнера. Сейчас не пойдём в детали, а только рассмотрим общий план оптимизации.
Рассмотрим на примере php.
В первую очередь, так называемая послойная сборка. Хорошо ускоряет пайплайны за счёт кэширования слоёв.
Следующий этап ー разделение образа на части.
Установка базовых модулей (все используемые на проде. до копирования кода в него)
Dev-слой. Установка таких вещей, как xDebug и прочее. Отдельный образ на основе предыдущего.
Установка вендоров с/без dev
Первых два, как редко изменяемые, выносим в отдельный репозиторий и версионируем в ручную. 3й ー автоматически версионируем по хешу composer.json и phive.xml. И храним 3й слои в основном репозитории. В двух версиях: с дев зависимостями и без них. Версию 3-го слоя меняем в коде и коммитим в ту же бранчу автоматически в том же пайплайне. Может быть немного заморочено, зато время сборки контейнера для большинства мердж-реквестов составляет считанные секунды.
Так в большинстве случаев мы используем готовый контейнер и не тратим время на его сборку.
Аналогично с другими контейнерами. Везде, где управляете модулями и глобальными настройками.
Прогреваем кэшь один раз для всех джоб пайплайна
Нам остаётся копирование кода в контейнер и прогрев кэша. Для этого мы создали отдельный шаг, отвечающий за сборку артифактов
'Build Common Artifacts':
stage: build-artifacts
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
artifacts:
untracked: true
expire_in: 7 days
script:
- bin/decode_files "." "${IV}" "${KEY}"
- bin/console cache:clear
variables:
APP_ENV: "test"
Далее, в базовом шаблоке для джоб тестов прописываем зависимость от этой джобы:
.run_tests: &run_tests
stage: tests
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
needs:
- job: 'Build Common Artifacts'
artifacts: true
Помечаем джобы как прирываемые
В GitLab есть замечательная инструкция для джоб
interruptible: true
Суть её в том, что если джоба не успела отработать, то следующий пуш в бранцу прервёт её выполнение. Это здорово освобождает ресурсы и ускоряет прохождение пайплайна.
Дробим джобы и параллелим
Дробим тесты на пресеты
Parallels ー шикарная утилита. Особенно для тестов в комбинации с Paratest. Позволяет запускать каждый отдельный тест в параллельных процессах. Это ещё сильнее сокращает общее время пайплайна.
Не смотря на то, что PHPStan работает в параллельных потоках по числу доступных ядер процессора. Этого бывает недостаточно. Дробим одну джобу PHPStan на несколько для проверки параллельно разных правил и возможно разных разделов проекта. Поэтому разбиваем на разные джобы. Он позволяет тонко настраивать в каких парках проверять код, а в каких только сканировать для правильного анализа использованных зависимостей.