Nix как менеджер зависимостей для C++
В последнее время много разговоров идет о том, что для C++ нужен свой пакетный менеджер подобный pip, npm, maven, cargo и т.д. Все конкуренты имеют простой и стандартизированный механизм подключения нестандартной библиотеки. В C++ же все действуют как умеют: кто-то прописывает в README список пакетов для Ubuntu, CentOS и других дистрибутивов, кто-то использует git submodule и скрипты для их сборки, кто-то использует CMake ExternalProject, кто-то копирует все исходники в один гигантский репозиторий, кто-то делает образ Docker или Vagrant.
Чтобы решить проблему был даже создан стартап — biicode, но он обанкротился и его будущее неизвестно. Взамен появился conan, дополняя зоопарк конкурентов — nuget, cget, hunter, cpm, qpm, cppget, pacm и даже gradle for c++.
Меня не устраивал ни один из перечисленных способов. Я было начал писать пакеты для Conan, но столкнулся с большим числом хаков, неразвитым API, отсутвием гайдлайнов и, как следствие, низкой вероятностью переиспользования чужих пакетов. И тут вспомнилось, что когда-то мне очень понравились идеи пакетного менеджера в NixOS. И подумал —, а зачем плодить пакетный менеджер специально для C++, если те же задачи решает обычный пакетный менеджер? Нужно только чтобы он был достаточно гибким и простым в части описания пакета. И Nix идеально подошел на эту роль.
Итак, что дал нам Nix:
- Возможность получить готовое к сборке проекта окружение одной командой —
nix-shell
; - 7344 готовых и поддерживаемых пакетов из
nixpkgs
; - Возможность создать производный пакет от пакета из репозитория (не копируя его код);
- Возможность указывать в зависимостях не только C/C++ библиотеки, но также необходимые инструменты (CMake, GCC), проекты из других экосистем (npm, pip), сервисы (redis);
- Возможность привязать окружение к коммиту. Это значит, что, например, ветка master может использовать boost 1.55, а devel — 1.60. При переходе от ветки к ветке Nix автоматически настроит окружение под нужную версию, причем это займет менее секунды (если сборка уже есть в кеше);
- Неинтрузивность — проект не зависит от Nix, его использование — личное дело каждого. Можно собрать все зависимости вручную (или вашим любимым пакетным менеджером), указав все правильные опции для cmake.
Nix — это функциональный язык программирования, заточенный под нужды пакетного менеджера (неудивительно, что он получил популярность в сообществе Haskell). Сборка пакета — это вычисление функции в Nix. И как положено функциональному языку программирования — повторные вызовы функции с теми же аргументами порождают одинаковый результат (бинарный пакет). А это значит, что пакеты можно кешировать, что Nix и делает — все сборки хранятся в /nix/store/$HASH-$PKGNAME
. Кроме того, можно проверить есть ли у кого-то другого в сети пакет с таким же хэшом, и если есть — скачать бинарный пакет у него.
Таким образом, «пакет» (здесь он называется derivation) в Nix — это функция, а «зависимости» — это аргументы этой функции. Что же такое репозиторий (NixPkgs
)? Это тоже функция, у которой нет аргументов, которая возвращает множество пакетов. Получается ли, что для использования репозитория нужно собрать все 7344 пакета? Нет! Nix — ленивый язык, а это значит ничего не будет вычисляться, пока оно явно не потребуется. А «потребовать» пакет можно утилитами.
Итак, прежде чем использовать Nix его нужно установить. Для этого можно либо использовать целый дистрибутив Linux (NixOS), либо установить пакетный менеджер отдельно для вашей любимой ОС (поддерживается Linux и MacOS). Все воздействия Nix будут ограничены каталогом /nix
и файлами в домашнем каталоге (~/.nix-channel
, .nix-defexpr
, .nix-profile
).
В ~/.nix-profile
хранятся симлинки на пакеты, которые запросил пользователь. Нам же нужно настроить окружение не для пользователя, а для проекта. Для этого используем утилиту nix-shell
: она выполняет данное на вход выражение Nix и запускает bash шелл, в котором доступен результат (и только он). Проверяем:
bash-3.2$ nix-shell -p stdenv
[nix-shell:~]$
Здесь в качестве выражения мы используем пакет (-p
) stdenv
. stdenv
— это минимальное окружение, которое содержит компилятор, make и другие самые необходимые вещи.
Если запустить nix-shell
без аргументов, то выражение читается из файла default.nix
. Создадим его:
{ pkgs ? import {} }:
let
stdenv = pkgs.stdenv;
in rec {
myProject = stdenv.mkDerivation {
name = "my-project";
};
}
Здесь мы написали функцию, которая на вход принимает репозиторий (а если параметр не задан — импортирует стандартный nixpkgs
) и возвращает «пакет» окружения нашего проекта. Добавим в него свежие CMake, Boost и Google Test из репозитория NixOS:
# ...
myProject = stdenv.mkDerivation {
name = "my-project";
nativeBuildInputs = [
pkgs.cmake
];
buildInputs = [
pkgs.boost
pkgs.gtest
];
};
Здесь buildInputs — зависимости, которые необходимы для сборки. Зачем еще nativeBuildInputs? Все дело в том, что Nix поддерживает кросс-компиляцию. И здесь мы говорим, что пакеты buildInputs должны быть собраны target тулчейном, а nativeBuildInputs нужно собрать обычным host тулчейном. Есть еще propagatedBuildInputs
— он добавляет зависимость всем пользователям пакета.
Теперь при следующем вызове nix-shell
, Nix выкачает необходимые бинарные пакеты и установит переменные окружения так, чтобы библиотеки находились стандартными средствами, например, CMake:
find_package(Boost 1.60 REQUIRED
COMPONENTS system thread)
find_path(GTEST_INCLUDE_DIRS
NAMES gtest/gtest.h
PATH_SUFFIXES gtest)
Разработчику остается лишь запустить cmake . && make
, о чем мы ему и сообщим при входе в nix-shell
:
myProject = stdenv.mkDerivation {
# ...
shellHook = [''
echo Welcome to myproject!
echo Run \'mkdir build && cd build && cmake .. && make -j\' to build it.
''];
};
Теперь мы хотим добавить в наш проект cppformat. Сначала ищем его в nixpkgs
:
$ nix-env -qaP | grep cppformat
$ nix-env -qaP | grep cpp-format
Пусто. Придется писать собственное выражение. Благо это всего 10 строчек. Добавим их в «let»:
# ...
let
stdenv = pkgs.stdenv;
fetchurl = pkgs.fetchurl;
cppformat = stdenv.mkDerivation rec {
version = "2.1.0";
name = "cppformat-${version}";
src = fetchurl {
url = "https://github.com/cppformat/cppformat/archive/${version}.tar.gz";
sha256 = "0h8rydgwbm5gwwblx7jzpb43a9ap0dk2d9dbrswnbfmw50v5s7an";
};
buildInputs = [ pkgs.cmake ];
enableParallelBuilding = true;
};
in rec {
# ...
buildInputs = [
# ...
cppformat
];
# ...
Теперь при последующем запуске nix-shell
, Nix скачает исходники cppformat, соберет их используя cmake (он видит, что проект использует cmake, поэтому вместо стандартного »./configure && make install
» будет использован »cmake . && make install
») и закеширует результат сборки в /nix/store
. Примечательно, что в отличие от утилит большинства других пакетных менеджеров:
- При неудаче в сборке исходники не будут выкачиваться повторно;
- Если мы изменили выражение — пакет перекомпилируется. Если потом решили откатить выражение назад, то автоматически будет использован старый пакет из кеша, даже если дата модификации файла изменилась (удобно при смене бранча/коммита).
Иногда нужный пакет в репозитории есть, но собран не так, как нам хочется. Нужно собрать его определенную версию, наложить патч, использовать определенные флаги. Nix позволяет это сделать без необходимости копипастить код из репозитория:
cpp-netlib = pkgs.cpp-netlib.overrideDerivation(oldAttrs: {
postPatch = ''
substituteInPlace CMakeLists.txt \
--replace "CPPNETLIB_VERSION_PATCH 1" "CPPNETLIB_VERSION_PATCH 3"
'';
cmakeFlags = oldAttrs.cmakeFlags ++ [ "-DCMAKE_CXX_STANDARD=11" ];
src = fetchFromGitHub {
owner = "cpp-netlib";
repo = "cpp-netlib";
rev = "9bcbde758952813bf87c2ff6cc16679509a40e06"; # 0.11-devel
sha256 = "0abcb2x0wc992s5j99bjc01al49ax4jw7m9d0522nkd11nzmiacy";
};
});
Мы можем собрать производный пакет X' на основе оригинального X из репозитория и использовать его у себя. При этом если какой-то пакет Y в репозитории зависел от X, то он продолжит использовать его старую версию. Но что если нужно изменить пакет внутри репозитория, т.е. так, чтобы его стали использовать 100500 других пакетов? И для этого случая в Nix есть инструменты. Пересоберем буст из nixpkgs
, используя GCC5 вместо стандартного GCC 4.9:
{ nixpkgs ? import {} }:
let
overrideCC = nixpkgs.overrideCC;
stdenv = if ! nixpkgs.stdenv.isLinux
then nixpkgs.stdenv
else overrideCC nixpkgs.stdenv nixpkgs.gcc5;
pkgs = nixpkgs.overridePackages (self: super: {
boost = super.boost.override { stdenv = stdenv; };
});
Здесь мы изменили имя аргумента с pkgs
на nixpkgs
и создаем производный репозиторий pkgs
, в котором буст собран так, как мы хотим. Теперь все остальные пакеты зависящие от boost должны быть пересобраны чтобы задействовать нашу сборку. Разумеется, будут (рекурсивно) пересобраны лишь те пакеты, которые используются внутри нашего выражения — ведь Nix ленив.
Тут все опять просто — в Nix есть поддержка сборки пакетов для .NET, Emacs, Go, Haskell, Lua, Node, Perl, PHP, Python и Rust. Для некоторых из них интеграция заключается в том, что Nix может использовать пакеты прямо из нативного пакетного менеджера:
nativeBuildInputs = [ pkgs.cmake pkgs.pkgconfig nodePackages.uglify-js ];
YouCompleteMe — пожалуй самый популярный движок автодополнения кода для C++, который не является частью IDE. Он вышел из Vim, но уже есть порты для Atom и, возможно, других редакторов. Если раньше разработчики должны были конфигурировать его самостоятельно под свою систему, то теперь мы можем сделать это универсально:
def ExportFromNix():
from subprocess import Popen, PIPE
import shlex
cmd = "nix-shell -Q --pure --readonly-mode --run 'echo $NIX_CFLAGS_COMPILE'";
proc = Popen(cmd, shell=True, stdout=PIPE)
out = proc.stdout.read().decode("utf-8")
return shlex.split(out)
flags += ExportFromNix()
Nix — одновременно гибкий, удобный и простой пакетный менеджер, который построен на принципах функционального программирования и претендует на роль пакетного менеджера для всего. Особенно он может быть удобен C/C++ программистам, т.к. позволяет заполнить пустующую у данного языка нишу. Используя его, можно патчить и добавлять библиотеки в проект не вызывая боль и ненавистить у коллег. А новичек, прибывший в команду, не будет тратить свои первые рабочие дни на сборку проекта.