Привет, Хабр! Меня зовут Александр Корнилов. Я старший разработчик в «Лаборатории Касперского» — в мобильном подразделении KasperskyOS. Так получилось, что большую часть своей жизни я занимался системным программированием. Сегодня хочу поднять важную и болезненную тему практически для всех С/С++-разработчиков — поговорить про билд-системы.

Систем сборки на выбор множество, а справляться они должны с целым списком задач: инкрементальная сборка, профили, тулчейны, кастомные таргеты, интеграция с IDE, артифактории. Не каждая система справляется со всеми одинаково хорошо, а выбрать подходящую для конкретной задачи — отдельная и масштабная головная боль для разработчика.

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

Для чего нужны и как работают системы сборки

Система сборки — программа, которая автоматически собирает и пересобирает код проекта по заранее заданным правилам. Берем исходные коды, непонятные железу, пропускаем через эту систему, на выходе получаем исполняемый бинарный артефакт.

Процесс построения в системе сборки — серия шагов по трансформации файла из одного формата в другой. Предположим, исходный файл у нас C++. Мы передаем его компилятору и на выходе получаем объектный файл. Передадим набор файлов-исходников C++ — получим несколько объектных файлов. Они поступают линковщику, создающему выполняемый файл или библиотеку.

Сценарий кажется простым, но на практике трансформаций может быть больше. Например, в 20-м стандарте появились модули с файлом описания: в процессе их компиляции появляются новые дополнительные файлы. Другой пример — Qt. На входе у него файлы C++, но мы не можем их напрямую отдать компилятору, поскольку Qt расширяет язык, появляются слоты, сигналы, эмиты. Сначала такой файл надо передать MOC-компилятору, который создаст из него C++-файл, соответствующий стандартам.

Подобная ситуация с графическими фреймворками, которые верстают UI не в C++, а в JSON, или XML, или ином своем варианте. Здесь перед компиляцией тоже надо сгенерировать из такого исходника плюсовый код. Система сборки должна поддерживать и разруливать подобные ситуации, и я еще опишу многие из них в соответствующем разделе ниже.

Система сборки состоит из базовых сущностей:

  • Цели (targets, goals)

  • Зависимости

  • Граф задач, или ориентированный ациклический граф (DAG), формирующийся из целей и их зависимостей

Граф задач определяет, в какой последовательности нужно выполнить цели, чтобы верно собрать код, и можно ли запараллелить выполнение некоторых целей. Чтобы выполнить каждую цель, для начала нужно выполнить все относящиеся к ней зависимости.

Системы сборки иногда делят на Task Oriented System и Artifact Oriented System; к последним относится, например, Bazel. В Artifact-Oriented-системах пользователи несколько ограничены в создании произвольных задач: под капотом все равно граф задач, но нужно описывать, что хотим получить на выходе, не указывая, как этот желаемый результат реализовать. У билд-инженера меньше свободы при написании билд-скрипта, что в некоторых случаях даже лучше: не приходится разбираться в огромном количестве целей и зависимостях между ними.

Типы целей

Теперь, когда понятны верхнеуровневые концепты систем сборки, давайте глубже разберемся в целях и в том, какие они бывают.

Начнем с абстрактных целей. У подобной цели есть имя и некоторые необязательные зависимости. Это самый простой вариант: обозначаем цель и перечисляем другие цели, которые необходимо выполнить, чтобы была выполнена эта.

Например, наша абстрактная цель — очистить билд-директорию. В Make, первой системе сборки, разработанной в 1976 году, это работает так:

.PHONY: clean
clean:
        rm -rf build

В CMake немного другой синтаксис:

add_custom_target(
  hello
  COMMAND "echo Hello, World!")

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

Вот как подобную цель прописывают в Make:

program: main.o
        cc -o program main.o

И в CMake:

add_custom_command(
    OUTPUT   that.txt
    COMMAND  generator --from this.txt
                       --produce that.txt
    DEPENDS  this.txt)

У C++-проектов часто встречаются такие типовые цели.

  • Создать выполнимый файл. Из исходников мы хотим получить файл, который можно выполнить. В ОС Windows это .exe, в Linux и Unix-подобных — ELF-файл.

  • Создать статическую библиотеку. Обычно в таком случае объектные файлы просто упаковываются в архив; никакой дополнительной работы с ними не производится.

  • Создать динамически подключаемую библиотеку. В отличие от статической библиотеки объектные файлы передаются линковщику, который ищет взаимосвязи между ними и генерирует образ разделяемой библиотеки. Ее можно загрузить в несколько процессов одновременно.

  • Создать бинарный артефакт для прошивки в устройство.

  • Установить бинарный артефакт в систему, чтобы он был доступен другим компонентам. Например, make install.

  • Выгрузить бинарный артефакт на устройство. Мы собрали файл; после этого вызываем цель, например upload или deploy, чтобы загрузить его на подключенный мобильный телефон.

  • Запустить юнит-тесты. Система сборки строит и запускает тесты по нашему запросу автоматически.

  • Запустить санитайзеры или анализаторы кода.

  • Сгенерировать документацию. Используем, например, Doxygen; пишем документацию в виде комментариев в определенном формате.

Чтобы перестать описывать все цели вручную, нужно повышать уровень абстракции. Так появились метасборочные системы. Их работа условно делится на две стадии.

  • Конфигурирование. Описываем то, что хотим построить, в удобном для человека высокоуровневом формате. Система сборки интерпретирует это описание и по нему генерирует make-файл со всеми синтаксическими особенностями.

  • Выполнение целей. Происходит в некоем инструменте, который хорошо выполняет цели, распараллеливает задачи и утилизирует CPU для быстрой реализации. Даже если сгенерированный файл очень большой, Make его переваривает и выполняет.

На этом принципе как раз основан всем известный CMake, который умеет генерировать файлы не только для Make, но и для Ninja, а также для других билд-систем.

Как масштабироваться

Допустим, мы собрали из наших DAG-молекул артефакт: библиотеку, исполняемый файл и т. п. Но, чтобы собрать большой проект, например ОС, в котором таких индивидуальных артефактов много, простой системы сборки уже не хватит. Нужен оркестратор, который стоит выше, над компонентами ОС, и понимает, как из них собрать итоговый образ. Обычно оркестратор использует в работе манифест сборки.

Манифест — входная точка, где описаны все компоненты, используемые в процессе сборки сложной системы, а между ними выстроены зависимости. У каждого компонента свой рецепт сборки. Оркестратор будет выполнять команды, которые в манифесте описаны, при этом компоненты могут собираться любой системой сборки. Главное, чтобы результат отвечал контракту, описанному в рецепте.

Когда мы пройдем по всему этому дереву, соберем все компоненты, сложим их в sysroot/buildroot, получится итоговый образ, который можно развернуть, запустить или упаковать в дистрибутив.

Примеры систем сборки

Разобрались в особенностях систем сборки для C++ и современных требованиях к ним, теперь посмотрим на некоторые примеры систем.

Make

Хотя этому мастодонту уже почти 50, используется он до сих пор. У Make очень специфический синтаксис: за пробел вместо Tab там грозит расстрел на месте.

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

В данный момент существуют более гибкие альтернативы, которые позволяют заменить Make на нечто похожее, например Ninja, Jam и прочее.

Yocto Linux и BitBake

Yocto — дистрибутив Linux, конструктор, который позволяет все сконфигурировать под себя: создать рецепты и получить собственный дистрибутив. Для создания большой системы в нем применяется оркестратор BitBake, написанный на Python.

Вот так может выглядеть рецепт:

DESCRIPTION = "cameracapture application"
 SECTION = "examples"
 LICENSE = "CLOSED"
 PR = "r0"
 DEPENDS = "opencv"
 SRC_URI = "git://github.com/zafrullahsyed/cameracapture.git;protocol=https;tag=v0.1"
 S = "{bindir}
     install -m 0755 cameracapture {bindir}
 }

Здесь мы задаем URL, откуда скачать исходники, а также говорим, что наследуемся мы от CMake, то есть наследуемся от готового модуля, который знает, как строить CMake-проекты. Также упоминаем, куда проинсталлировать результат.

Kaspersky Build (KB)

Про нашу собственную систему сборки тоже расскажу. Обычно нас спрашивают: «На каком дистрибутиве Linux основан KasperskyOS?» Ни на каком. KasperskyOS — полностью оригинальная микроядерная операционная система со своей экосистемой и повышенным требованием к надежности и устойчивости к кибератакам.

Кибериммунность — одно из основных свойств KasperskyOS, влияющих и на ее построение. В архитектуру заложены свойства, позволяющие не пользоваться наложенными средствами, например антивирусами, гарантируя определенную безопасность на уровне ядра. Это:

  • проприетарное микроядро;

  • изоляция доменов;

  • контроль межпроцессорных взаимодействий.

Например, драйверы в KOS — просто приложения в user space, отдельный домен безопасности. Драйвер падает, а ядро не ложится, процесс просто перезапускается.

На платформе KOS строится ряд продуктов: встроенные системы для роутеров, тонкий клиент с графикой, смартфон и т. д.

Поскольку все это необходимо было учесть в системе сборки, мы не стали использовать готовую, а написали собственный оркестратор: Kaspersky Build (KB). В его основе Make, но при этом система сборки имеет модульную архитектуру — при запуске можно выбрать, какие модули использовать, а компоненты могут собираться на CMake, Make, Meson и т. д. Можно дописать свои расширения. Рецепты компонентов также написаны на Make.

Еще одна важная фича — поддержка манифестов для повторяемых сборок. Когда проект успешно собрался, мы можем зафиксировать эту конфигурацию, версии компонентов, хеш-суммы при клонировании git-репозиториев и другие данные, чтобы в будущем в точности ее повторить. Это очень полезно, например, для воспроизведения бага.

Система поддерживает настраиваемые конфигурации для сборки разных продуктов на базе KasperskyOS. Перед началом сборки мы выбираем, допустим, мобильную платформу: конфиг или манифест. Оркестратор тут же понимает, какой набор компонентов нужно включить и что с ними в итоге сделать.

Пример рецепта для KB приводить не буду, поскольку это закрытая система. Основные данные, которые в нем указываются, — источник исходников, команды для сборки и установки, зависимости и то, для каких архитектур компонент разрешен.

Особенности сборки C++-проектов

Собирать C++-проекты — процесс довольно сложный, есть много особенностей, с которыми система должна справляться. Перечислю основные.

Зависимости, возникающие из заголовочных файлов. Мы создали цель — скомпилировать файл из main.cpp. На выходе ожидаем app.exe. Но внутри main.cpp есть include, которые отрабатывает препроцессор. Это происходит транзитивно, один header включает другой, третий, четвертый и т. д. Получается дерево, которое может осложниться разветвлениями через условные проверки макросов препроцессора: if одно, то включи это, else включи другое. Такие зависимости могут получиться очень сложными.

Лучше всего о зависимостях знает компилятор: именно он понимает, какие макросы были определены и как отработаны препроцессором. Обычно у компиляторов есть опция генерировать список всех зависимостей для исходного файла. А система сборки должна понимать, что, если изменилась хотя бы одна из зависимостей, цель нужно перестраивать.

Макросы определений препроцессора. Мы можем передать их на этапе сборки или определить в скриптах сборки, а система сборки должна понимать, что они есть, и поддерживать их.

Поиск заголовочных файлов и библиотек для линковки. Обычно передают список системных папок. Но для сторонних компонентов система сборки тоже должна передавать в компилятор, где искать заголовочные файлы или библиотеки.

Тонкие настройки самого компилятора. В плюсах есть множество интересных и тонких настроек компилятора и линковщика. Вот несколько простых примеров: оптимизация производительности, размера; включение LTO (Link Time Optimization), с которыми система должна уметь работать.

Поддержка различных профилей сборки. Release, Debug, Profile, Code Coverage — все это необходимо учитывать.

Работа с зоопарком тулчейнов и бинарная совместимость в рамках этого зоопарка. MSVC, GCC, Clang, разные наборы утилит компиляторов могут быть несовместимы между собой. При использовании компилятора от Microsoft лучше использовать и линковщик от них же. Если при сборке тулчейн меняется, система сборки должна пересобрать проект.

Взаимодействие с другими языками программирования. Рабочие биндинги к другим языкам (например, к ассемблеру) тоже могут лечь на систему сборки.

Генерация C++-файлов во время построения, например, из IDL-файлов. Эта возможность также используется в KasperskyOS. Мы в IDL-файлах описываем интерфейс, взаимодействие сущностей: оно должно быть прозрачно для монитора безопасности. Во время сборки мы по ним генерим код, который должен неявно включаться в проект.

Требования к современной системе сборки

Требования опираются на особенности сборки в C++, но при этом субъективны и определяются прежде всего конкретным проектом. Поэтому этот список — во многом мои скромные пожелания по поддержке из коробки или с помощью несложных манипуляций.

Инкрементальная сборка. Система сборки должна перестраивать только то, что действительно изменилось. Думаю, большинство современных систем это поддерживает.

Распараллеливание сборки. Проекты бывают тяжелые, поэтому желательно использовать все возможности процессора. Или хотя бы иметь возможность указать, сколько ядер использовать.

Анализ зависимостей заголовочных файлов. Система сборки должна учитывать все зависимости: заголовочные файлы и прочие вещи.

Модульная архитектура самой системы сборки с поддержкой плагинов. Плагины удобны, когда во всех проектах нужны дополнительные функции: их можно не копировать в скрипт сборки, а вынести в отдельный модуль.

Поддержка различных тулчейнов, например как плагинов. Было бы здорово, если бы для использования другого тулчейна не приходилось переписывать половину скрипта.

Поддержка абстрактных настроек тулчейнов. На мой взгляд, удобно, когда можно дать задачу системе сборки, не вдаваясь в подробности, какой ключ компилятора отвечает за ее реализацию. В разных тулчейнах ключи могут быть различны. Абстрактные настройки позволяют просто сказать системе сборки, что нужно определить макрос. Зная, какой тулчейн используется, она сама выберет нужные опции компилятора.

Кроссплатформенность. Это не критично, если проект создается под конкретную платформу. Но в целом я за кроссплатформенность, когда одной командой можно собирать и под Windows, и под Linux, и под Mac, не задумываясь об особенностях платформы.

Поддержка кросс-компиляции на другую платформу и под другую архитектуру. Возможно, это не такая уж востребованная штука, но нам она полезна, когда, например, мы строим проект для KasperskyOS под Linux или для ARM под X86. Система сборки должна понимать, что в качестве таргета ей передают другую архитектуру или платформу, и использовать соответствующий инструментарий.

Расширенная интеграция с IDE. Удобно написать скрипт сборки и открыть его в IDE так, чтобы весь код пропарсился. Для C++ в современных IDE чаще всего используется clangd: с ним можно ориентироваться по коду, углубиться в рефакторинг и т. д. Хорошо, когда все это работает из коробки.

Поддержка пакетов (бинарных артифакториев). В С++ долго шли к общей стандартизации. Даже сейчас нельзя сказать, что она есть. Безусловно, есть Conan, но это сторонний инструмент. Однако удобно, если система сборки понимает, что такое пакеты и как ими оперировать. Указываем, что есть пакет определенной версии и после запуска он будет автоматически в бинарном виде подгружаться или собираться при необходимости. Например, если нет готового бинаря под текущую платформу или он несовместим с тулчейном. В коде с ним можно будет легко работать.

Удобный язык написания сценариев сборки. Было бы хорошо, если бы мы имели возможность не ограничиваться DSL (Domain Specific Language), а опираться на что-то более развитое. На этом еще остановлюсь далее.

Поддержка юнит-тестов. Хорошо, если система сборки автоматически создает цель для запуска юнит-тестов, если обнаруживает их в проекте. Особенно если язык не поддерживает встроенные юнит-тесты.

Поддержка подпроектов и зависимостей между ними. Было бы удобно, если бы система сборки поддерживала выставление зависимостей на подпроект, например библиотеку, которая после этого включалась бы в сборку. Чтобы при этом в настройках компилятора для основного проекта автоматически прописывались пути к публичным заголовочным файлам библиотеки, а в линковщик передавалась собранная библиотека без дополнительных телодвижений разработчика.

Современная система сборки: Gradle

Я не так давно открыл Gradle для себя. Возможно, вам она тоже понравится. Это современная кроссплатформенная система сборки. Появилась она не так давно и активно развивается. Обычно она ассоциируется с Java, Android и т. д. Возможно, изначально Gradle создавалась под Java, но сейчас она работает со многими языками и поддерживает удобную систему написания плагинов, за счет которой можно удобно встраивать новые языки программирования.

Gradle — не «генератор», как CMake, а полноценная самодостаточная система, выполняющая задачи всего цикла от и до. Написана на Java. Не очень быстрая, особенно при первом запуске: дело в выкачивании различных артефактов. После него Gradle запускается значительно быстрее: все уже развернуто, а демон сборки работает на «прогретой» JVM.

Язык сценариев сборки — Groovy или Kotlin на выбор, с элементами DSL. Сценарий сборки в Gradle выглядит, как DSL, похож на config, но при этом позволяет использовать конструкции языка общего назначения — классы, функции и т. д. При написании сценариев можно опираться на всю мощь экосистемы Java, например доступен весь арсенал библиотек Maven Central.

Что умеет Gradle для C++

Для C++ в Gradle написан достаточно качественный и проработанный плагин. Он создавался и поддерживается самими разработчиками Gradle. Прямо из коробки Gradle поддерживает самые популярные тулчейны: MSVC, GCC, Clang. Она сразу настраивает два профиля сборки: debug и release.

Система предлагает удобную раскладку исходных файлов. Благодаря этому в каждом скрипте не нужно перечислять, где лежат исходники. Он автоматически найдет файлы там, где их ожидает. Предложенную раскладку можно поменять, но, если вы не создаете ничего экстраординарного, схемы по умолчанию будет достаточно.

Gradle из коробки поддерживает удобно реализованный запуск юнит-тестов для C++. Предусмотрена интеграция с IDE: для C++ она реализована через генерацию проектных файлов. Необходимо выполнить специальную цель, например сгенерировать файлы для Microsoft Visual Studio и открыть сгенерированные файлы в IDE.

У Gradle очень дружелюбные API для написания собственных плагинов. Причем там предусмотрены разные варианты их написания, о которых я расскажу подробнее. Система предоставляет бесплатный общедоступный хостинг для авторских плагинов. Пройдя определенную процедуру, можно опубликовать свой и использовать в абсолютно любом проекте. Его не нужно развертывать локально, а можно поставить прямо с портала.

А еще разработчики реализовали, на мой взгляд, очень удобную на практике штуку для упаковки, публикации и использования артефактов по аналогии с Java Maven Central. Это репозиторий, куда каждый желающий может выкладывать разные библиотеки. Можно указать, что надо использовать данный репозиторий и скачивать нужные артефакты с заданной версией.

Аналогичная система работает для C++. Разработчики придумали, как упаковывать плюсовые библиотеки в бинарные артефакты, чтобы использовать их в других местах. Это просто папка в файловой системе, которую можно опубликовать и использовать локально или даже выложить на файловый сервер. Это удобно, но в других системах сборки я такого не видел. Далее расскажу, как это устроено под капотом.

Сценарий сборки

Давайте рассмотрим подробнее простейший сценарий сборки C++-проекта на примере сборки библиотеки. Сценарий состоит из нескольких секций. Первая сверху — plugins.

plugins {
    id 'cpp-library'
    id 'cpp-unit-test'
}

 version = '0.1'

 library {
    linkage = [Linkage.STATIC]
}

Указываем, что используем cpp-library для сборки проекта и cpp-unit-test для тестирования, прописываем версию библиотеки и говорим, какая она. Library появляется после того, как мы подключили плагин cpp-library, добавляющий поддержку данной секции, — это и есть DSL. Выглядит, как будто мы задаем в фигурных скобках параметры, а на самом деле вызывается функция. Здесь мы говорим, что хотим собрать статическую библиотеку.

Остальную работу Gradle сделает за вас. Система по соглашению ожидает, что существует папка src, в ней main и внутри лежат исходники проекта. Стандартный layout файлов в C++-проекте библиотеки:

  • src/main/cpp — корневая папка для основных файлов .cpp проекта;

  • src/main/headersпапка для внутренних заголовочных файлов, которые не будут задействованы при публикации;

  • src/main/publicпапка для публичных заголовков;

  • src/test/cppпапка для файлов .cpp юнит-теста.

Gradle заточена на многоязычные проекты, поэтому папки называются cpp. В соответствующей папке рядом может лежать java, и исходники не будут конфликтовать.

На мой взгляд, это достаточно логичное и удобное разделение. Если оно вас не устраивает, в разделе library можно указать SourceSet: передать путь к другой папке с исходниками. Можно указать всю папку или перечислить конкретные файлы. Когда в папках с исходниками появятся новые файлы, проект автоматически перестроится.

Gradle позволяет определять макросы. Это несколько сложнее, но не уводит от абстрактного вида: мы не лезем во флаги компилятора. Проходим по конфигурации всех binaries (debug, release, test) и в соответствующую map-у добавляем, что при компиляции будет определен еще один макрос.

library {
    linkage = [Linkage.SHARED]

     binaries.configureEach {
        compileTask.get().macros['CUSTOM_MACRO_77'] = '1'
    }
}

Для проекта C++ со стандартной целью Gradle принимает команды assemble и assembleRelease для сборки, test для тестирования. Тестирует она интеллектуально: запоминает, что тесты собрались, выполнились и при этом прошли успешно, поэтому не запустит их повторно, если в проекте ничего не изменилось.

Зависимости и публикация артефактов

Скрипт чуть посложнее иллюстрирует работу с зависимостями:

plugins {
    id 'cpp-library'
    id 'cpp-unit-test'
    id 'maven-publish'
}

group = 'loggersoft.cpp.samples'
version = '0.1'

repositories {
    maven {
        url = 'https://gtest-gradle.sourceforge.io/maven'
    }
}
library {
    linkage = [Linkage.SHARED]

    dependencies {
        implementation project(':logger')
    }
}

unitTest {
    dependencies {
        implementation 'com.google:gtest:1.8.1'
    }
}

publishing {
    repositories {
        maven {
            url = "$buildDir/repo"
        }
    }
}

В начале мы подключаем библиотеки. Отличие от прошлого примера: добавлен maven-publish, плагин, который позволяет публиковать. У него добавлена поддержка С++. Group и version — координаты, то есть уникальный идентификатор артефакта в терминах Maven. Описываем репозиторий Maven по определенному URL: в нем лежит С++-артефакт com.google:gtest:1.8.1 в формате Gradle.

Далее используем библиотеку logger. Теперь она входит в проект Gradle, и она понимает, что logger нужно перестроить и включить в билд. Для тестирования мы хотим получить зависимость com.google:gtest такой-то версии.

В конце мы публикуем библиотеку. Я выбрал публикацию в $buildDir/repo. После публикации (> gradle publish) мы получим в этой папке бинарный артефакт, которым можно пользоваться локально или выложить в сеть.

> gradle publishToMavenLocal автоматически публикует артефакт в локальном репозитории в папке пользователя.

Что внутри артифактория

Разберу, что представляет собой публичный артифакторий для C++. В каждой папке лежит все необходимое, чтобы подключить библиотеку.

loggersoft/
├── cpp-mate
│   ├── 0.9
│   │   ├── cpp-mate-0.9-cpp-api-headers.zip
│   │   ├── cpp-mate-0.9.module
│   │   └── cpp-mate-0.9.pom
│   └── maven-metadata-local.xml
├── cpp-mate_debug_linux
│   ├── 0.9
│   │   ├── cpp-mate_debug_linux-0.9.a
│   │   ├── cpp-mate_debug_linux-0.9.module
│   │   └── cpp-mate_debug_linux-0.9.pom
│   └── maven-metadata-local.xml
├── cpp-mate_debug_windows
│   ├── 0.9
│   │   ├── cpp-mate_debug_windows-0.9.lib
│   │   ├── cpp-mate_debug_windows-0.9.module
│   │   └── cpp-mate_debug_windows-0.9.pom
│   └── maven-metadata-local.xml
├── cpp-mate_release_linux
│   ├── 0.9
│   │   ├── cpp-mate_release_linux-0.9.a
│   │   ├── cpp-mate_release_linux-0.9.module
│   │   └── cpp-mate_release_linux-0.9.pom
│   └── maven-metadata-local.xml
└── cpp-mate_release_windows
    ├── 0.9
    │   ├── cpp-mate_release_windows-0.9.lib
    │   ├── cpp-mate_release_windows-0.9.module
    │   └── cpp-mate_release_windows-0.9.pom
    └── maven-metadata-local.xml

10 directories, 20 files

loggersoft, 3 группа, далее идет имя артефакта и версия. Все публичные header, которые положили в папку public или явно указали в скрипте, упакованы в zip-архив cpp-mate-0.9-cpp-api-headers.zip, рядом есть файлы .module, .pom etc. Там лежат контрольные суммы, позволяющие верифицировать артефакт при развертывании.

Далее в своих папках идут различные версии артефакта: отладочная, релизная, для Windows, для Linux.

Решение нестандартных задач

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

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

  1. Файл лежит на FTP-сервере.

  2. Нужно получить список файлов в определенной папке и отфильтровать только один.

  3. Это должна быть картинка в формате PNG (фильтр по расширению).

  4. Необходимо скачать самый «свежий» файл (фильтр по дате модификации файла).

  5. Если мы не запускаем цель «тесты», ничего качаться не должно.

Для начала посмотрим, как бы мы решали подобную задачу с CMake.

Можно воспользоваться download. Но по условиям нам нужно качать из сети, только когда запускаются юнит-тесты, а здесь закачка начнется уже на стадии конфигурирования. Не подходит.

Может вот такая «крякозябра»? Она умеет качать средствами самого CMake на стадии построения, но для наших условий недостаточно гибкая.

Остается звать на подмогу bash или внешний скрипт, например на Python.

Но возможны сложности с переносимостью и производительностью. Допустим, bash на Windows работать не будет. Или в системе должен присутствовать Python, то есть мы вступаем в область, где возможны разные сюрпризы. Если вы эксперт в CMake, подскажите в комментариях наиболее подходящее на ваш взгляд решение. Я простого пути «в лоб» так и не нашел.

А в Gradle мы можем использовать Kotlin для написания сценария сборки. На мой взгляд, так получается описать логику более чисто, абстрактно. Привел весь исходный код подобного сценария с комментариями, чтобы было понятно, что происходит. Не нужно пугаться, что написанный класс в примере — объемный. Его можно поместить в библиотеку и переиспользовать.

// Напишем небольшой враппер над Apache FTP-client,
// чтобы скрыть рутинные детали
class FtpClientWrapper(uri: String,
                       username: String,
                       password: String): Closeable, AutoCloseable {

    private val FTP_PROTOCOL = "ftp"

    val url: URL = URI(uri).toURL()

    val client = FTPClient().apply {
        require(url.protocol == FTP_PROTOCOL)
        configure(FTPClientConfig(FTPClientConfig.SYST_UNIX))
        connect(url.host, if (url.port <= 0) FTPClient.DEFAULT_PORT
                          else url.port)
        login(username, password)
        enterLocalPassiveMode()
    }

    override fun close() {
        client.logout()
        client.disconnect()
    }
}

// Враппер логинится и автоматически закрывается, когда мы перестаем // к нему обращаться.

// Внутри этой секции опишем зависимости,
// необходимые для работы самого скрипта сборки
buildscript {
    repositories {
        // Хотим воспользоваться
        // артифакторием Maven Central
        mavenCentral()
    }

    dependencies {
       // Нам нужен FTP-клиент из
       // библиотеки Apache Commons Net (чтобы не писать его руками)
        classpath("commons-net:commons-net:3.10.0")
    }
}

// Создадим универсальную (готовую для переиспользования) задачу
// для скачивания файла с FTP-сервера по заданным параметрам
// для этого наследуемся от дефолтной задачи

open class FtpFileTask: DefaultTask() {
    // Два следующих поля будут использоваться Gradle
    // для обеспечения инкрементальной сборки - если URL не менялся
    // и выходной файл не был удален, эта задача выполнится один
    // раз

    // с помощью аннотаций указываем, какие данные будут входными,
    // а какие выходными
    @get:Input
    val url: Property<String> = project.objects.property()
    // на входе - URL, с которого скачиваем

    @get:OutputFile
    val outputFile: RegularFileProperty = project.objects.fileProperty()
    // на выходе - файл

    // Далее идут внутренние поля для настройки скачивания (Gradle их не учитывает)
    @Internal
    var username: String = "anonymous"
    @Internal
    var password: String = ""
    @Internal
    lateinit var filter: (Array<FTPFile>) -> FTPFile?

    @TaskAction
    fun execute() {
        FtpClientWrapper(url.get(), username, password).use {
            val file = filter(it.client.listFiles(it.url.path))
            check(file != null)
            print("Downloading '${file.name}’... ")
            BufferedOutputStream(FileOutputStream(outputFile.asFile.get())).use { stream ->
                it.client.retrieveFile(File(it.url.path, file.name).path, stream)
            }
            println(“[OK]");
        }
    }
}
// Создаем экземпляр нашей задачи, настраиваем все поля для
// скачивания и регистрируем в Gradle
// здесь мы скачиваем файл, кладем его в Output

tasks.register<FtpFileTask>("provisionRecentPng") {
    outputFile = File(layout.buildDirectory.asFile.get(), "recent.png")
    url = "ftp://test.rebex.net/pub/example"
    username = "demo"
    password = "password"

// логика по фильтрации спрятана в этих строчках:
  
    filter = { files -> files.filter { it.isFile &&
                                       it.name.trim()
                                              .lowercase()
                                              .endsWith(".png")
                                     }.maxByOrNull { it.timestamp }
             }
}

tasks.test {
    // Выставляем зависимость для юнит-тестов на нашу задачу
    dependsOn("provisionRecentPng")
}

Мы опираемся на мощь Java-экосистемы, чтобы выполнить свой билд. При сборке основного проекта эта зависимость не дергается. А если собираем тест, перед тестированием будет вызвана задача, файл скачается в файловую систему. Для тестирования будет все готово, повторный запуск не заставит повторять эти действия:

$ gradle provisionRecentPng

> Task :provisionRecentPng
Downloading 'KeyGenerator.png’... [OK]

BUILD SUCCESSFUL in 402ms
1 actionable task: 1 executed

$ gradle provisionRecentPng

BUILD SUCCESSFUL in 42ms
1 actionable task: 1 up-to-date

В общем, мы пользуемся всеми преимуществами языка общего назначения.

Написание собственных плагинов для Gradle

Когда я начал разбираться с Gradle, понял, что для моих небольших и средних проектов это удобная вещь, но мне не хватало некоторого функционала. И я сел писать свои плагины. Хотел посмотреть, как все это работает, и в итоге даже опубликовал несколько плагинов на бесплатном Gradle Plugin Portal для разработчиков.

Для публикации нужно зарегистрироваться на сайте, создать личный кабинет и получить ключи для подписи артефактов. В самой Gradle есть «плагин для публикации плагинов», процесс автоматизирован. После проверки плагин появляется в центральном репозитории. Выглядит скрипт сборки для публикации плагина примерно так:

plugins {
    id 'java-gradle-plugin'
    id 'com.gradle.plugin-publish' version '1.0.0'
}

group = 'loggersoft'
version = '1.9'

pluginBundle {
    website = 'https://gradle-cpp.sourceforge.io'
    vcsUrl = 'http://hg.code.sf.net/p/gradle-cpp/code'
    tags = ['cpp', 'C++', 'gcc', 'g++', 'clang', 'visual', 'tuner', 'adjuster', 'build', 'debug', 'release', 'cpp-application', 'cpp-library', 'cpp-unit-test']
}

gradlePlugin {
    plugins {
        cppBuildTunerPlugin {
            id = 'loggersoft.cpp-build-tuner'
            displayName = 'C++ build tuner'
            description = 'The plugin performs C++ build tunning according to current build variant: debug or release.'
            implementationClass = 'loggersoft.gradle.cpp.buildtuner.PluginImpl'
        }
    }
}

Здесь есть все необходимое — теги, описание, расположение плагина и его исходников, класс, который реализует основной интерфейс плагина.

У CMake есть аналог плагинов — модули, которые включаются в скрипт. Соответственно, при каждом запуске билда они будут парситься, на это будет уходить время. А плагины для Gradle — скомпилированные jar-файлы, готовые бинарные артефакты, которые просто встраиваются в систему сборки.

Далее расскажу о нескольких разработанных мной плагинах.

Плагин cpp-build-tuner

Первый плагин — это билд-тюнер. Позволяет настраивать различные ключи компилятора, которые я привык выставлять для разных тулчейнов. В новой версии я реализовал цель для запуска приложения с помощью run и добавил возможность изменения текущего тулчейна при запуске: его можно передать непосредственно в параметрах.

> gradle build -Dtoolchain=mingw
> gradle run --args="arg1 arg2 ..."

Плагин позволяет изменять стандарт языка, включать LTO и генерацию базы данных компиляции, поддерживаемую clangd, чтобы парсить проект в IDE.

plugins {
    id 'cpp-library'
    id 'cpp-unit-test'
    id 'loggersoft.cpp-build-tuner' version '1.9'
}

buildTuner {

    lto = false

    gtest = '1.8.1'

    libraries {
        common = ['cutils.lib']
        windows = ['ole32', 'user32']
        linux = ['pthread', 'z']
    }

    libDirs.common = ['../build/debug', '../release']

    defines {
        common = ['DEF_COMMON_1', ' DEF_COMMON_2 =  7 ']
        windows = [' DEF_WINDOWS_1', ' DEF_WINDOWS_2=9']
        linux = ['DEF_LINUX_1  ', ' DEF_LINUX_2  =3 ']
    }

    dependencies.common = ['loggersoft.jvm:class-reader:0.1']
    standard = 17
}

Здесь я создаю свою секцию настройки, которая называется buildTuner, в которой определяю параметры с помощью функций и классов. Выглядит, как конфигурация, но под капотом DSL-функции, классы и лямбды с получателем.

Плагин cpp-ide-generator

Следом я реализовал плагин, которые генерирует проектные файлы для разных IDE:

  • Eclipse CDT

  • Qt Creator

  • NetBeans

  • KDevelop

  • VS Code (clangd)

В скрипте сборки можно указать этот плагин с версией; он автоматически скачает с портала все необходимое и будет работать.

plugins {
    id 'cpp-library'
    id 'cpp-unit-test'
    id 'loggersoft.cpp-ide-generator' version '1.0'
}

ide {
    autoGenerate = false
    eclipse = true
    qtCreator = true
    netBeans = true
    kdevelop = true
}

В разделе IDE указывается, что нужно генерировать. Можно сделать так, чтобы файлы автоматически генерировались при первом билде. Далее достаточно просто открыть результат в IDE, и он пропарсится.

> gradle generateIde[IDE name]
> gradle cleanIde[IDE name]

cleanIde удаляет сгенерированные проектные файлы.

Плагин cpp-conan

Совсем недавно опубликовал новый плагин: интеграцию с Conan — пакетным менеджером для C++.

В CMake необходимо сначала писать conan.txt, там описывать свои зависимости и опции, а далее запускать генератор для CMake в Conan. В Gradle можно просто включить мой плагин, указать с помощью DSL и составить раздельные списки необходимых библиотек с версиями для основного проекта и для тестов. Зависимости, указанные только для теста, не будут подгружаться при построении основного проекта. После этого все поедет. Вот пример скрипта сборки, чтобы было ясно, о чем речь:

plugins {
`cpp-application`
`cpp-unit-test`

id("net.sf.loggersoft.cpp-conan") version "0.1"
}

conan {
//requires("zstd") version "1.5.6" option ("shared" to "true")
//requires("zstd") version "1.5.6" shared true
requires("zstd") version "1.5.6"

requires("boost") version "1.86.0" option ("zstd" to "false") option ("lzma" to "false")

//requires("bzip2") version "1.0.8"
//requires("boost") version "1.70.0#revision2"
//requires("poco") version "[>1.0 <1.9]" option ("shared" to "true")

//testRequires("gtest") version "1.15.0" shared true
testRequires("gtest") version "1.15.0"

build = "~zlib"
//remote = "https://center2.conan.io"
//offline = true
}

Плагин cpp-llvm

Кто работал с LLVM, представляет, насколько проблемно бывает с ней интегрироваться, особенно когда надо билдить под Windows и Linux одновременно. Обычно бинарники под Linux можно скачать с их сайта, перечислить к ним пути, а для Windows бинарные артефакты не публикуются. Вот я и написал плагин для Gradle. В нем можно указать версию LLVM, shared или static, а также необходимые компоненты.

plugins {
    id 'cpp-application'
    id 'loggersoft.cpp-llvm' version '2.2'
}

llvm {
    version = '13.0.0'
    linkage = Linkage.SHARED
    components = ['Engine', 'OrcJIT']
}

При запуске билда все необходимое скачается и раскатается само. Можно задать дополнительные цели для получения информации:

> gradle llvmVersions
> gradle llvmBuildInfoRelease

Резюме и дополнительное чтение

Мне Gradle показалась более абстрактным инструментом в сравнении с тем же CMake. За счет своей архитектуры она позволяет простыми и понятными способами реализовывать сложные процессы. Если вы работаете с кроссплатформенными проектами или хотите больше гибкости в настройке сборки, рекомендую к ней присмотреться.

Если тема сборки и тонкая настройка инструментов вас цепляет, как и меня, возможно, нам по пути. Мы как раз ищем C++-разработчиков, которым интересны сложные задачи и инженерный подход. Присоединяйтесь, будем билдить вместе.

А всем, желающим глубже погрузиться в тему сборки, советую дополнительные материалы:

Комментарии (2)


  1. dersoverflow
    29.08.2025 15:55

    простые проблемы обязаны иметь простые решения. точка.

    как показала Практика, пользователям вполне достаточно обычного bat файла. например, вот так собирается DebRel:

    @set CC=g++
    @set FLAGS=-static -std=c++14 -Ofast -DNDEBUG -pedantic -Wall
    @set DERSLIB=..\derslib
    @set INC=-I%DERSLIB%\inc
    %CC% %FLAGS% %INC% *.cpp %DERSLIB%\src\unity\build.cpp %DERSLIB%\src\posix\unity\build.cpp -o debrel 1>1.build 2>2.build

    да, можно спорить, что разработчикам (не пользователям) необходима сложная утилита "с полным отслеживанием" зависимостей... вот только в них ОБЯЗАТЕЛЬНО есть проблемы! как знают дедушки, видишь ДИЧЬ - сделай полный ребилд! ага, тем самым батником быстрее будет.

    ЗЫ никто ж не спорит, что распределенная система сборки БОЛЬШОГО проекта - must have! т.к. зачем ждать шесть часов, если можно на десять минут. вот только малые и средние проекты... не надо тянуть бегемотов, чья сложность больше, чем у задачи ;)

    ЗЗЫ https://habr.com/ru/posts/924552/


  1. Chelyuk
    29.08.2025 15:55

    У меня самой большой проблемой было отсутствие понимания связи переменных в IDE. В связи с чем разобраться в системах сборки крайне сложно.
    В одном из проектов использовался Make. Но, как обычно, были какие-то переменные окрыжения, которые останавливались bash скрипом, а то и сверху делают какой-нибудь Python сурипт, скажем для определения таргета и развязки зависимостей от платформы.
    В результате, читать это всë и понимать очень сложно. Нашëл переменную и ищешь еë просто поиском по всму, потому что никогда не угадаешь каким именно образом она задается и в каком файле.
    В результате у меня вопрос на который я не могу найти ответа. Ведь в проекте все равно будет какой-нибудь CI, скажем условный GitLab CI. Как по мне, так проще написать на нкго 1 раз пайплайн, благо эта тема исписана вдоль и поперёк. И если хочется билдить локадьно - прикрутить в контейнере CI agent ноду и использовать всë тот же пайплайн. Как по мне ты получаешь стандартное решение понятное большинству. Плюс, избегаешь мороки с поиском переменых. Они все так же стандартно задаются через знакомый CI. Нет нужды прикручивать и обкладыватьмя скриптами.