Nix как менеджер зависимостей для C++

в 15:02, , рубрики: c++, cmake, devops, nix, nixos, package manager, Разработка под Linux

Nix loves 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

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 <nixpkgs> {} }:
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.
     ''];
   };

Собираем зависимость, которой нет в nixpkgs

Теперь мы хотим добавить в наш проект 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 <nixpkgs> {} }:
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 ];

Интегрируем Nix в YouCompleteMe

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++ программистам, т.к. позволяет заполнить пустующую у данного языка нишу. Используя его, можно патчить и добавлять библиотеки в проект не вызывая боль и ненавистить у коллег. А новичек, прибывший в команду, не будет тратить свои первые рабочие дни на сборку проекта.

Автор: snizovtsev

Источник

Поделиться новостью

* - обязательные к заполнению поля