Собираем C++ с bazel
Введение и мотивация
В последнее время на Хабре появляются посты про то, что cmake и c++ — друзья, приводятся примеры, как собирать header-only библиотеки и не только, но нет обзора хоть сколько-нибудь новых систем сборки — bazel, buck, gn и других. Если вы, как и я, пишете на C++ в 20к20, то предлагаю вам познакомиться с bazel как системой сборки c++ проекта.
Оставим вопросы, чем плохи cmake и другие существующие системы, и сконцентрируемся на том, что может сам bazel. Решать, что лучше конкретно для вас, я оставляю конкретно вам.
Начнем с определения и мотивации. Bazel это мультиязычная система сборки от гугла, которая умеет собирать c++ проекты. Почему мы вообще должны смотреть на еще одну систему сборки? Во первых, потому что ей уже собираются некоторые большие проекты, например Tensorflow, Kubernetes и Gtest, и соответственно чтобы интегрироваться с ними уже нужно уметь пользоваться bazel. Во вторых, кроме гугла bazel еще использует spaceX, nvidia и другие компании судя по их выступлениям на bazelcon. И наконец, bazel это довольно популярный open-source проект на github, так что он определенно стоит того чтобы на него взглянуть и попробовать.
Пример 1. Тривиальный
Есть main.cc и надо его скомпилировать:
main.cc
#include
int main() {
std::cout << "Hello, habr" << std::endl;
return 0;
}
Начинается все с объявления workspace. В терминах bazel workspace это директория в которой лежат все ваши исходные файлы. Чтобы этот workspace обозначить надо создать пустой файл с именем WORKSPACE в нужной нам директории, обычно это директория src.
Минимальной единицей организации кода в bazel является пакет. Пакет определяется директорией с исходниками и специальным BUILD файлом, который описывает как собираются эти исходники.
Добавим пакет main в наш проект:
В BUILD файле мы должны теперь описать что мы хотим собрать из нашего main. Мы, естественно, хотим собрать исполняемый бинарный файл, так что будем использовать правило cc_binary. Bazel уже из коробки поддерживает C++, так что уже есть определенный набор правил для сборки c++ целей, с остальными мы познакомимся дальше.
Добавляем правило cc_binary в BUILD файл, у него есть имя, которое будет иметь исполняемый файл и массив исходников которые будут переданы компилятору. Все это описывается на языке starlark, который является урезанным питоном.
cc_binary(
name = "hello_world",
srcs = "main.cc"
)
Bazel, в отличие от cmake не основывается на командах, а позволяет декларативно описывать зависимости через правила. По сути, правила связывают несколько артефактов с определенной операцией. С помощью них bazel строит граф команд, который затем кэширует и исполняет. В нашем случае исходный файл main.cс связывается с операцией компиляциии, результатом которой будет артефакт hello_world — бинарный исполняемый файл.
Чтобы теперь получить наш исполняемый файл мы должны перейти в директорию с workspace и набрать:
bazel build //main:hello_world
Система сборки принимает команду build и путь до нашей цели, начинающийся от корня нашего проекта.
Итоговый бинарник будет расположен в bazel-bin/main/hello_world.
Пример 2. Сборка со своей библиотекой
К счастью, такие простые проекты особо никому не нужны, так что давайте посмотрим как добавить функциональности в наш проект. Добавим библиотеку, которая будет собираться отдельно и линковаться с нашим main.
Пусть это будет Square, библиотека, которая будет предоставлять не двусмысленную функцию возведения в квадрат. Добавление новой библиотеки означает добавление нового пакета, назовем его также square.
square.h
#ifndef SQUQRE_SQUARE_H_
#define SQUQRE_SQUARE_H_
int Square(int x);
#endif // SQUQRE_SQUARE_H_
square.cc
#include "square/square.h"
int Square(int x) {
return x * x;
}
Обратите внимание на подключение заголовочного файла, я делаю это через путь от workspace, даже несмотря на то, что файл лежит в той же самой директории. Такой подход принят в chromium code style guide, который наследуется от google c++ style guide. Такой способ позволяет сразу понимать откуда подключается заголовочный файл. Не волнуйтесь, файл найдется, bazel добавит пути для поиска заголовочных файлов, а вот если вы не будете следовать этому правилу, то заголовочные файлы могут и не найтись при сборки bazel«ом.
В BUILD файле нашей библиотеки описываем правило для сборки библиотек cc_library:
cc_library(
name = "square",
srcs = ["square.cc"],
hdrs = ["square.h"],
visibility = ["//visibility:public"]
)
Тут мы перечисляем отдельно исходники и заголовочные файлы, а также указываем видимость в public. Последнее нужно для того, чтобы мы могли зависеть от нашей библиотеки где угодно в нашем проекте.
В main.cc мы используем нашу библиотеку:
#include
#include "square/square.h"
int main() {
std::cout << "Hello, bazel!" << std::endl;
std::cout << Square(2) << std::endl;
return 0;
}
Опять обращаю внимание на то, что подключаем заголовочный файл библиотеки через путь от workspace. Тут уже это точно необходимо, потому что bazel под капотом использует линукс контейнеры, чтобы обеспечить минимальный уровень герметичности сборки и, соответственно, он подмонтирует заголовочные файлы библиотеки square именно так, чтобы они находились через путь от workspace.
И описываем зависимость в правиле сборки для main от библиотеки square.
cc_binary(
name = "hello_world",
srcs = ["main.cc"],
deps = ["//square:square"]
)
Собирается вся наша программа точно также, как и без использования библиотеки, bazel сам поймет что от чего зависит, построит граф, закеширует результаты и пересоберет только то, что нужно пересобрать.
bazel build //main:hello_world
Пример 3. Подключение тестов
Как вообще жить без тестов? Никак! Чтобы подключить к bazel GTest, который к слову уже поддерживает сборку с bazel нужно добавить внешнюю зависимость. Делается это в WORKSPACE файле:
load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
git_repository(
name = "googletest",
remote = "https://github.com/google/googletest",
tag = "release-1.8.1"
)
Все как у хипстеров, подключили правило git_repository и указали bazel«у какую версию скачать.
Дальше создаем отдельный пакет для тестов test и добавляем в него тесты на нашу библиотеку:
square_unittest.cc
#include "gtest/gtest.h"
#include "square/square.h"
TEST(SquareTests, Basics) {
EXPECT_EQ(Square(-1), 1);
EXPECT_EQ(Square(1), 1);
EXPECT_EQ(Square(2), 4);
}
Теперь настал черед определение правила для тестов.
cc_test(
name = "unittests",
srcs = ["square_unittest.cc"],
deps = [
"//square:square",
"@googletest//:gtest_main"
]
)
Добавили зависимости от нашей библиотеки и от gtest_main, чтобы библиотека gtest сама предоставила нам реализацию лаунчера.
Запускаются тесты командой:
bazel test //test:unittests
Bazel сам скачает и построит GTest, слинкует все что надо для тестов и запустит сами тесты.
Упомяну, что bazel также умеет делать code coverage:
bazel coverage //test:unittests
А если нужно отладить тесты, то собрать все в debug режиме с символами можно так:
bazel build //test:unittests --compilation_mode=dbg -s
Пример 4. Подключение других библиотек, которые не умеют в bazel
Конечно, мир не строится на одном лишь bazel, так что надо уметь подключать и другие библиотеки. Недавно в своем проекте мне понадобилась библиотека для разбора аргументов командной строки. Ну и не писать же мне в 20к20 свою такую библиотеку и отвлекаться от основной работы. Использовать какие-то полумеры, вроде getops тоже очень не хочется, как и тащить boost в свой проект.
Не для рекламы ради подключим библиотеку CLI11, которая не использует ничего лишнего кроме кроме stl стандарта C++11 и предоставляет более-менее удобный интерфейс.
Библиотека это header-only, что делает её подключение особенно простым.
Подключаем внешнюю зависимость в WORKSPACE:
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
http_file(
name = "CLI11",
downloaded_file_path = "CLI11.hpp",
urls = ["https://github.com/CLIUtils/CLI11/releases/download/v1.9.0/CLI11.hpp"],
sha256 = "6f0a1d8846ed7fa4c2b66da3eb252aa03d27170258df...",
)
Добавляем директорию third-party и добавляем пакет CLI11 для удобства построения зависимостей от этой библиотеки:
cc_library(
name = "CLI11",
hdrs = ["@CLI11//file"],
strip_include_prefix = "/external/CLI11/file",
include_prefix = "CLI11",
linkstatic = True,
visibility = ["//visibility:public"],
)
Bazel по умолчанию будет искать файл с библиотекой по пути /external/CLI11 так что мы немного поменяем пути чтобы подключать его по CLI11/.
main.cc
#include
#include "CLI11/CLI11.hpp"
#include "square/square.h"
int main() {
std::cout << "Hello, bazel!" << std::endl;
std::cout << Square(2) << std::endl;
return 0;
}
В зависимости main добавляем »//third_party/CLI11: CLI11» и все начинает работать.
Не знаю как вам, но подключение какой-то незнакомой библиотеки и её использование в проекте на c++ в таком виде приводит меня в восторг.
Да, с header-only библиотекой вы скажете что все просто, но с не header-only библиотекой, которая еще не собирается с bazel все точно также просто. Вы просто скачиваете её через http_archive или git_repository и добавляете ей внешний BUILD файл в third-party директорию, где и описываете как собрать вашу библиотеку. Bazel поддерживает вызов любого cmd и даже вызов cmake, через правило cmake_external.
Пример 5. Скрипты и автоматизация
Кому в 20к20 нужен проект на голом c++ без скриптов для автоматизации? Обычно такие скрипты нужны для запуска перф тестов или для деплоя ваших артефакты куда-нибудь на CI. Ну и обычно они пишутся на питоне.
Для этого bazel тоже годится, так как он умеет почти во все популярные языки и как раз и предназначен для того, чтобы собирать такие солянки из разных языков программирования, которые так часто встречаются в реальных проектах.
Давайте подключим питон скрипт, который будет запускать наш main.
Добавляем пакет perf-tests:
py_binary(
name = "perf_tests",
srcs = ["perf_tests.py"],
data = [
"//main:hello_world",
],
)
В качестве зависимости по данным добавляем зависимость от бинарника hello_world.
perf_tests.py
import subprocess
import time
start_time = time.time()
process = subprocess.run(['main/hello_world, 'param1', ],
stdout=subprocess.PIPE,
universal_newlines=True)
end_time = time.time()
print("--- %s seconds ---" % (end_time - start_time))
Для того чтобы запустить наши тесты просто пишем:
bazel run //perf-tests:perf_tests
Заключение и то, чего не коснулись
Мы коротко посмотрели на bazel и его основные возможности для сборки исполняемых файлов и библиотек, как сторонних, так и своих. На мой вкус получается довольно лаконично и очень быстро. Не надо страдать и искать какой-нибудь туториал по cmake чтобы сделать какую-то тривиальную вещь и чистить CmakeCache.
Если вам будет интересно, то за бортом осталось еще много всего: подключение protocol buffers, санитайзеры, настройка тулчейна чтобы компилировать под разные платформы/архитектуры.
Спасибо за прочтение, и надеюсь я был вам полезен.