Build C ++ with bazel

Introduction and Motivation


Recently, posts have appeared on Habr that cmake and c ++ are friends, examples are given of how to collect header-only libraries and not only, but there is no overview of at least some new build systems - bazel, buck, gn and others. If you, like me, write in C ++ in 2k20, then I suggest you get acquainted with bazel as a build system for a c ++ project.

We will leave the question of what cmake and other existing systems are bad for and concentrate on what bazel itself can do. To decide what is best specifically for you, I leave it specifically for you.

Let's start with definition and motivation. Bazel is a multilingual Google build system that can build c ++ projects. Why should we even look at another build system? Firstly, because some large projects are already going to her, for example Tensorflow, Kubernetes and Gtest, and accordingly, to integrate with them, you already need to be able to use bazel. Secondly, besides google bazel still uses spaceX, nvidia and other companies judging by their performances on bazelcon. Finally, bazel is a pretty popular open source project on github, so it's definitely worth a look and try it out.

Example 1. Trivial


There is main.cc and you need to compile it:

main.cc

#include <iostream>

int main() {
   std::cout << "Hello, habr" << std::endl;
   return 0;
}

It all starts with a workspace declaration. In terms of bazel workspace, this is the directory in which all your source files are located. To designate this workspace, you need to create an empty file with the name WORKSPACE in the directory we need, usually this is the src directory.

The minimum unit for organizing code in bazel is a package. The package is defined by the source directory and a special BUILD file that describes how these sources are assembled.

Add the main package to our project:



In the BUILD file, we must now describe what we want to build from our main. Naturally, we want to compile an executable binary, so we will use the cc_binary rule. Bazel already supports C ++ out of the box, so there is already a certain set of rules for building c ++ goals, we will get to know the rest later.

Add the cc_binary rule to the BUILD file, it has a name that will have an executable file and an array of sources that will be passed to the compiler. All of this is described in starlark, which is a truncated python.

cc_binary(
  name = "hello_world",
  srcs = "main.cc"
)

Bazel, unlike cmake, is not based on commands, but allows declaratively describe dependencies through rules. Essentially, rules associate multiple artifacts with a specific operation. Using them, bazel builds a command graph, which then caches and executes. In our case, the main.cc source file is associated with a compilation operation, the result of which is the artifact hello_world - a binary executable file.

To now get our executable, we must go to the directory with workspace and type:

bazel build //main:hello_world

The build system accepts the build command and the path to our goal, starting from the root of our project.

The resulting binary will be located at bazel-bin / main / hello_world.

Example 2. Build with your library


Fortunately, nobody needs such simple projects, so let's see how to add functionality to our project. Add a library that will be built separately and linked to our main.

Let it be Square, a library that will provide an unambiguous squaring function. Adding a new library means adding a new package, let's also call it 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;
}

Pay attention to the connection of the header file, I do it through the path from the workspace, even though the file is in the same directory. This approach is adopted in the chromium code style guide, which is inherited from google c ++ style guide. This method allows you to immediately understand where the header file is connected from. Do not worry, there will be a file, bazel will add paths to search for header files, but if you do not follow this rule, then header files may not be found during the bazel build.

In the BUILD file of our library, we describe the rule for building the cc_library libraries:

cc_library(
  name = "square",
  srcs = ["square.cc"],
  hdrs = ["square.h"],
  visibility = ["//visibility:public"]
)

Here we list separately the source and header files, and also specify the visibility in public. The latter is necessary so that we can depend on our library anywhere in our project.

In main.cc we use our library:

#include <iostream>
#include "square/square.h"

int main() {
  std::cout << "Hello, bazel!" << std::endl;
  std::cout << Square(2) << std::endl;
  return 0;
}

Again, I draw attention to the fact that we include the header file of the library through the path from workspace. This is already absolutely necessary here, because bazel uses Linux containers under the hood to ensure a minimum level of tightness of the assembly and, accordingly, it will mount the square library header files just so that they are located through the path from the workspace.

And we describe the dependency in the assembly rule for main on the square library.

cc_binary(
  name = "hello_world",
  srcs = ["main.cc"],
  deps = ["//square:square"]
)

Our entire program is assembled in the same way as without using the library, bazel itself will understand what it depends on, build a graph, cache the results and rebuild only what needs to be rebuilt.

bazel build //main:hello_world

Example 3. Connecting tests


How to live without tests? No way! To connect to bazel GTest, which by the way already supports assembly with bazel, you need to add an external dependency. This is done in the WORKSPACE file:

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"
)

Just like hipsters, they connected the git_repository rule and told bazel which version to download.

Next, we create a separate package for test tests and add tests to our library into it:

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);
}

Now itโ€™s the turn to define a rule for tests.

cc_test(
  name = "unittests",
  srcs = ["square_unittest.cc"],
  deps = [
   "//square:square",
   "@googletest//:gtest_main"
  ]
)

We added dependencies on our library and on gtest_main so that the gtest library itself would provide us with a launcher implementation.

Tests are run with the command:

bazel test //test:unittests

Bazel will download and build GTest itself, link everything that is needed for the tests and run the tests themselves.

I mention that bazel also knows how to do code coverage:

bazel coverage //test:unittests

And if you need to debug tests, then you can compile everything in debug mode with characters like this:

bazel build //test:unittests --compilation_mode=dbg -s

Example 4. Connecting other libraries that do not know how to bazel


Of course, the world is not built on bazel alone, so you need to be able to connect other libraries as well. Recently, in my project, I needed a library to parse command line arguments. Well, not to write to me in 2k20 your own such library and be distracted from the main work. I really do not want to use any half measures, like getops, as well as drag boost into my project.

Not for advertising, weโ€™ll connect the CLI11 library, which uses nothing more than the stl standard C ++ 11 and provides a more or less convenient interface.

A library is header-only, which makes connecting it especially easy.

Connect the external dependency in 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...",
)

We add the third-party directory and add the CLI11 package for the convenience of building dependencies on this library:

cc_library(
  name = "CLI11",
  hdrs = ["@CLI11//file"],
  strip_include_prefix = "/external/CLI11/file",
  include_prefix = "CLI11",
  linkstatic = True,
  visibility = ["//visibility:public"],
)

Bazel will by default look for the library file by the path / external / CLI11 so that we change the paths a bit to connect it via CLI11 /.

main.cc

#include <iostream>

#include "CLI11/CLI11.hpp"
#include "square/square.h"

int main() {
  std::cout << "Hello, bazel!" << std::endl;
  std::cout << Square(2) << std::endl;
  return 0;
}

Depending on main, add "// third_party / CLI11: CLI11" and everything starts to work.
I donโ€™t know about you, but connecting some unfamiliar library and using it in a c ++ project in this form delights me.

Yes, with the header-only library you will say that everything is simple, but with a non-header-only library that is not yet built with bazel everything is just as simple. You simply download it via http_archive or git_repository and add an external BUILD file to it in the third-party directory, where you describe how to build your library. Bazel supports calling any cmd and even calling cmake, through the cmake_external rule.

Example 5. Scripts and automation


Who needs a project in bare c ++ in 2k20 without scripts for automation? Typically, such scripts are needed to run perf tests or to deploy your artifacts somewhere to CI. Well, usually they are written in python.

For this, bazel is also suitable, since it can be used in almost all popular languages โ€‹โ€‹and is designed to collect such solyanka from different programming languages โ€‹โ€‹that are so often found in real projects.

Let's connect the python script that will run our main.

Add the perf-tests package:

py_binary(
  name = "perf_tests",
  srcs = ["perf_tests.py"],
  data = [
    "//main:hello_world",
  ],
)

As a data dependency, add the hello_world binary dependency.

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))

In order to run our tests, we simply write:
bazel run //perf-tests:perf_tests

Conclusion and what is not touched


We briefly looked at bazel and its main features for assembling executable files and libraries, both third-party and our own. For my taste, it turns out pretty concisely and very quickly. No need to suffer and look for some cmake tutorial to do some trivial thing and clean CmakeCache.

If you are interested, then there is still a lot left over: connecting protocol buffers, sanitizers, setting up a tool chain to compile for different platforms / architectures.

Thanks for reading, and I hope I was useful to you.

All Articles