[Перевод] 13. Nix в пилюлях: Паттерн проектирования callPackage

277ab3ed054478b9ef672b0fde6292da.jpg

Добро пожаловать на тринадцатую пилюлю Nix. В предыдущей двенадцатой пилюле мы рассказали о первом из базовых паттернов проектирования репозиториев. Кроме того, мы подготовили graphviz, чтобы в нашем учебном репозитории было два пакета.

Следующий паттерн проектирования, с которым мы познакомимся, называется callPackage. Подход, который здесь описан, широко применяется в nixpkgs и в сейчас является стандартом де-факто при импорте пакетов.

Удобство callPackage

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

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

{ input1, input2, ... }:
...

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

rec {
  lib1 = import package1.nix { inherit input1 input2; };
  program2 = import package2.nix { inherit inputX inputY lib1; };
}

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

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

Для этого нам потребуется такая функция callPackage, чтобы её можно было вызывать подобным образом:

{
  lib1 = callPackage package1.nix { };
  program2 = callPackage package2.nix { someoverride = overriddenDerivation; };
}

Надо, чтобы callPackage была функцией с двумя аргументами, которая делает следующее:

  • Импортирует выражение, заданное в файле, который передаётся в первом аргументе, и возвращает функцию. Полученная функция возвращает деривацию пакета, при этом деривация использует паттерн Входящие.

  • Определяет имена аргументов функции, то есть имена входящих параметров деривации.

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

Реализуем callPackage

В этом разделе мы реализуем паттерн callPackage с нуля. Для начала разберёмся, как получать имена аргументов функции во время выполнения. В нашем случае речь идёт о функции, которая создаёт деривацию. Зная имена, мы сможем передавать аргументы автоматически.

Для этого Nix предоставляет встроенную функцию:

nix-repl> add = { a ? 3, b }: a+b
nix-repl> builtins.functionArgs add
{ a = true; b = false; }

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

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

Для этого нам нужны две вещи:

  • Набор репозитория с деривациями, чьи имена совпадают с именам полученных нами аргументов.

  • Способ автоматически объединить набор атрибутов репозитория и значения, полученные из functionArgs.

Чтобы достичь первой цели, достаточно в качестве имён аргументов использовать имена пакетов репозитория, например, nixpkgs. Вторая цель достигается благодаря ещё одной встроенной функции Nix:

nix-repl> values = { a = 3; b = 5; c = 10; }
nix-repl> builtins.intersectAttrs values (builtins.functionArgs add)
{ a = true; b = false; }
nix-repl> builtins.intersectAttrs (builtins.functionArgs add) values
{ a = 3; b = 5; }

intersectAttrs возвращает набор атрибутов, имена которых — это пересечение имён атрибутов обоих аргументов, а значения получены из второго аргумента.

Это всё, что нам нужно: мы получили имена аргументов из функции и заполнили их значениями из существующего набора атрибутов. Вот простая учебная реализация callPackage:

nix-repl> callPackage = set: f: f (builtins.intersectAttrs (builtins.functionArgs f) set)
nix-repl> callPackage values add
8
nix-repl> with values; add { inherit a b; }
8

Проанализируем этот фрагмент кода:

  • Мы определяем функцию callPackage.

  • Первый параметр функции callPackage — это набор пар имя-значение. Часть из них может совпадать с набором аргументов вызываемой фукнции.

  • Второй параметр — вызываемая функция.

  • Мы получаем имена аргументов функции и находим их пересечение с набором всех значений.

  • Наконец, мы вызываем функцию f с полученным пересечением.

Фрагмент выше демонстрирует, что вызов callPackage эквивалентен прямому вызову add a b.

У нас получилось почти всё, что мы хотели: мы вызываем функции автоматически, передавая им набор аргументов.
Если аргумент в наборе не найден, мы получаем ошибку (если только у функции не переменное число аргументов, объявленных через ..., как мы объясняли в пятой таблетке).

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

nix-repl> callPackage = set: f: overrides: f ((builtins.intersectAttrs (builtins.functionArgs f) set) // overrides)
nix-repl> callPackage values add { }
8
nix-repl> callPackage values add { b = 12; }
15

Код довольно ясный, не смотря на выросшее количество скобок.
Здесь мы всего-навсего объединяем набор аргументов по умолчанию с набором переопределений.

Используем callPackage, чтобы упростить код репозитория

Имея на руках функцию callPackage, мы можем упростить выражение репозитория в default.nix:

let
  nixpkgs = import  { };
  allPkgs = nixpkgs // pkgs;
  callPackage =
    path: overrides:
    let
      f = import path;
    in
    f ((builtins.intersectAttrs (builtins.functionArgs f) allPkgs) // overrides);
  pkgs = with nixpkgs; {
    mkDerivation = import ./autotools.nix nixpkgs;
    hello = callPackage ./hello.nix { };
    graphviz = callPackage ./graphviz.nix { };
    graphvizCore = callPackage ./graphviz.nix { gdSupport = false; };
  };
in
pkgs

Разберёмся, как это работает:

  • Выражение выше определяет наш собственный репозиторий пакетов, который мы называем pkgs. Он содержит пакет hello и два варианта пакета graphviz.

  • В выражении let мы импортируем nixpkgs. Обратите внимание, что раньше мы ссылались на это значение с помощью переменной pkgs, но теперь это имя зарезервировано за репозиторием, который мы создаём.

  • Нам нужен способ как-то передать pkgs в callPackage. Вместо того, чтобы напрямую вернуть набор пакетов из default.nix, мы сначала присваиваем его переменной в выражении let, что позволяет передать его в callPackage.

  • В целях упрощения, мы передаём в callPackage не функцию, а имя файла, из которого callPackage эту функцию импортирует. Иначе бы нам пришлось вручную импортировать каждый пакет.

  • Поскольку наши выражения используют пакеты из nixpkgs, в callPackage мы описываем переменную allPkgs, в которую помещаем пакеты из nixpkgs и наши пакеты.

  • Мы переместили функцию mkDerivation в pkgs, чтобы она передавалась в параметрах автоматически.

Обратите внимание, как легко мы переопределили аргументы для создания graphviz без gd. Кроме того, обратите внимание, как легко слить два репозитория — nixpkgs и наш pkgs!

Читатель должен заметить магическую вещь, которая здесь происходит. Чтобы определить pkgs, мы используем callPackage, а при определении callPackage мы используем pkgs. Эта магия работает благодаря ленивым вычислениям: builtins.intersectAttrs не должен знать все значения из allPkgs, чтобы найти пересечение, а только ключи, ни один из которых не требует вычисления callPackage.

Заключение

Паттерн »callPackage» значительно упростил код репозитория. Мы смогли импортировать пакеты с именованными аргументами и вызывать их автоматически, при этом пользуясь пакетами из nixpkgs.

Также мы познакомились с полезными встроенными функциями, которые позволили нам получить информацию о функциях Nix и манипулировать атрибутами. Эти встроенные функции обычно используются не для установки пакета, а, скорее, как инструменты при установке. Они описаны в Руководстве по Nix.

Создание репозитория в Nix — это эволюция разработки удобных функций для комбинирования пакетов. Эта пилюля демонстрирует, как Nix может быть универсальным инструментом для сборки и развёртывания программ и как он подходит для создания репозиториев, которые следуют нашим собственным соглашениями.

В следующей пилюле

В следующей пилюле мы поговорим о паттерне проектирования »переопределение». Пакет graphvizCore кажется простым. Он использует скрипт graphviz.nix и собирает пакет без поддержки gd.
В следующей пилюле мы посмотрим на этот процесс с другой точки зрения: может быть стоит начать с функции pkgs.graphviz и отключать gd при её вызове?

© Habrahabr.ru