【经验分享】我如何学习写 Nix derivation

入坑 Emacs 块一年半了,入坑 NixOS 也有几个月了。入坑 NixOS 的第一时间 我就尝试用 Nix 管理 Emacs 的包,对于 melpa 中的包来说,这是十分简单的, 但是对于像 Emacs Application Framework 这种,不在 melpa 中,没有现成的 Nix 包(居然没人打包?),还伴有 Python 和 NodeJS 之类的复杂依赖的包, 就十分困难了。所以我一开始只能在 NixOS 中配置 Python 和 NodeJS 环境, 然后 Git clone EAF 仓库,手动构建。但是这并不是“Nix way”,不符合了声明 式配置的初衷,不够优雅。因此我最近花了“一点”时间学习并实践,途中遇到了 许多疑惑与困难;在此不得不感叹目前网上关于 Nix 打包的讨论是真的少,中 文内容更少(悲)。于是我打算分享一下我写 Nix 程序并 debug 的经验,而且 我没找到一个合适的 NixOS 中文社区,考虑了一下发现还是 Emacs 社区最合适。

本文旨在让完全不了解 Nix 的友人们能快速地理解 Nix 语言、derivation 的 工作原理以及 debug 方法。本人资历尚浅,提出的方法未必是(大概率不是) “最佳实践”,也无法保证没有知识性错误,还请各位批判性地阅读。

参考文献/推荐阅读:

11 个赞
  • Nix 语法极简介绍

首先我们极其精简地介绍一下 Nix 最基础的部分语法。

属性集:使用大括号包围,内容为零个或多个键-值对,每个键-值对结尾需要分 号。示例:{ a = 1; b = 2; }

列表:使用中括号包围,内容为零个或多个项,项之间用空格(或换行)分隔。 示例:[ 1 2 ]

抽象(通常叫函数定义,但我更喜欢 lambda 演算中的叫法):使用冒号分隔形 参和函数体(返回值),形参为单个符号或属性集匹配符。示例:x: x + 1{ x, y }: x + y

应用(通常叫函数调用):函数 参数,如果函数或参数最外面是括号,也可以 不要空格直接将二者挨在一起。示例:(x: x + 1) 5, ({ x, y }: x + y) { x = 3; y = 4}

下一节介绍在 NixOS 中安装、配置软件的基础操作。

  • NixOS 中如何安装并配置软件

要在 NixOS 中安装软件,首先在官网 NixOS Search 上搜 索选项,例如搜索 firefox,发现有选项programs.firefox.enable,在配置 文件中设置其为 true 即可安装。并且通常除了 enable 之外,还会搜索到其他 选项,例如programs.firefox.autoConfigFiles,就可以顺便把软件的配置工 作也给完成了。

如果没有选项,那就在 NixOS Search (其实这和上面那 个不是同一个超链接)上搜索包,例如搜索 ripgrep,发现没有选项,但是有包, 那么就在配置文件中加上environment.systemPackages = [ pkgs.ripgrep ]即可安装,但是配置就没有选项那么方便。

如果都没有,那就自己打包吧(悲)。

还有一点要注意一下,我们谈到的 Emacs 的包,并不是独立运行的软件,而是 Emacs 的插件,虽然插件也是包,也能搜索到(例如 emacsPackages.magit), 但是将其放在environment.systemPackages中是没有效果的,正确做法是用 withPackages函数去“修改”原始软件,形如emacs-gtk.withPackages (epkgs: [ epkgs.magit ]),这里的参数是一个函数,参数我命名为 epkgs, 可以认为它相当于 pkgs.emacsPackages,所以函数返回列表中直接使用 epkgs.magit,一般不写 pkgs.emacsPackages.magit。下一步再将修改后的 emacs-gtk 放入environment.systemPackages。有时候会有相关选项方便我们 操作,例如home-manager 提供了programs.emacs.extraPackages选项,将 epkgs: [ epkgs.magit ]放到这里即可。

下一节将介绍为 Nix 打包的基础知识。

  • Nix 打包:derivation

在 Nix 中,打包就是制作 derivation,所谓 derivation 就是描述一个包的构 建过程,实践中通常使用 Nix 自带的各种mkDerivation函数,例如 stdenv.mkDerivation

Nix 构建一个包的大致思路是:将“源”放到一个“临时路径”(然后可能要“打补 丁”),然后“配置”与“构建”(编译就在这里完成),接着“安装”到“最终路径” (最后可能还要“修补”一下)。

下一节将举例说明这个构建的流程。

  • 分析现成示例:rime-japanese

rime-japanese 是 rime 输入法的一个插件,在 NixOS 官网可以搜到它的包, 这个包足够简单,适合作为入门教材。在官网中,我们点击这个包下的 source 按钮查看其 derivation 的源码,发现是这样的:

{
  lib,
  stdenvNoCC,
  fetchFromGitHub,
}:
stdenvNoCC.mkDerivation {
  pname = "rime-japanese";
  version = "0-unstable-2023-08-02";

  src = fetchFromGitHub {
    owner = "gkovacs";
    repo = "rime-japanese";
    rev = "4c1e65135459175136f380e90ba52acb40fdfb2d";
    hash = "sha256-/mIIyCu8V95ArKo/vIS3qAiD8InUmk8fAF/wejxRxGw=";
  };

  installPhase = ''
    runHook preInstall

    install -D japanese.*.yaml -t $out/share/rime-data/

    runHook postInstall
  '';

  meta = {
    description = "Layout for typing in Japanese with RIME";
    homepage = "https://github.com/gkovacs/rime-japanese";

    # Awaiting upstream response (gkovacs/rime-japanese#6)
    # Packages are assumed unfree unless explicitly indicated otherwise
    license = lib.licenses.unfree;
    maintainers = with lib.maintainers; [ pluiedev ];
    platforms = lib.platforms.all;
  };
}

源码链接:

整个文件是一个抽象,它的参数是一个属性集,并且需要有libstdenvNoCCfetchFromGitHub这三个字段;它的返回值是一个应用,将一 个很长的属性集应用到stdenvNoCC.mkDerivation上,这正是上文提到的众多 mkDerivation函数中的一个,而stdenvNoCC是指去除 gcc 等编译工具后的 标准环境,考虑到这个 derivation 根本不需要编译构建,只是复制文件,也就 用不着 gcc,因此使用这个环境是合理的。

接下来我们分析占据了一大半篇幅的那个属性集。

pname 和 version 字段顾名思义,指定包名称和版本,最终在/nix/store/中 生成的仓库名称会是<hash>-<pname>-<version>,理论上这两个字段可以随便 写,但是指定合适的名字可以方便我们找到仓库,检查其中的内容是否正确。

src 字段指定构建过程中的“源”,它的值是一个应用,将一个属性集应用到 fetchFromGitHub函数上,意思是根据这个属性集的信息,从GitHub 上拉取源 码;属性集指定了所有者,仓库名,版本号和哈希值,哈希值可以先不管,到时 候报错了就会提示正确的哈希值的(乐)。

installPhase 字段指定构建过程中的“安装”步骤。我们没有指定“打补丁”、“构 建”等步骤,Nix 就会按默认的来,在stdenvNoCC中,可以认为它除了把源码 放到临时路径之外啥也没干。installPhase 的值是一个字符串(Nix 特有的多 行字符串语法,使用两个单引号包裹),内容是一个 bash 脚本,指定要干什么 事情;其中runHook preInstallrunHook postInstall是运行钩子,如果 在installPhase 之外,还写了 preInstall 和/或 postInstall 字段,那么它就 会调用对应的 bash 脚本,但这里没有指定,那就没效果;唯一有效果的 是install -D japanese.*.yaml -t $out/share/rime-data/语句,运行 install 指令,将临时路径中形如 japanese.*.yaml 的文件安装到最终仓库路 径下的 share/rime-data/ 文件夹。在这个 bash 脚本里,“当前工作路径” cwd 会是临时路径,而环境变量 out 会是最终路径。至于为什么放到 share/rime-data 文件夹,这个到时候再说。

最后 meta 字段指定了一些“元信息”,例如开源协议、维护者等,如果要将 derivation 发布就必须指定,但它对包的构建其实没有什么影响,自己用时可 以不要。

下一节将介绍如何使用 derivation。

3 个赞

支持,如果有更 nix 的方式来管理 eaf 的话就太棒了

nix 我感觉是真的难学,文档又乱又少,很多还没文档,有的文档还落后版本

2 个赞

奇怪,nixpkgs 里大多的包好像是自动生成的,就介绍一下常用的几个函数就可以上手大多数情况了。

其实像我这样的菜鸟,完全可以用 doomemacs

我也不太明白为什么有人说难,你大概学个入门,慢慢用 nixpkgs,看更新日志和论坛什么的,自然而然就会好起来的。

  • 如何使用 derivation

首先要说明一点:将 rime-japanese 激活的方法比较独特,参考 Package request: rime-japanese · Issue #295527 · NixOS/nixpkgs · GitHub ,以下为一个示例:

  i18n.inputMethod.fcitx5.addons = [
    (pkgs.fcitx5-rime.override {
      rimeDataPkgs = [
        pkgs.rime-data
        pkgs.rime-japanese
      ];
    })
  ];

核心是fcitx5-rime.override函数,它会修改 fcitx5-rime 包中的部分参数, 修改的内容由后面的属性集决定。在这里我们修改了 rimeDataPkgs 参数,它是 一个列表,原本只有 rime-data 那一项(可以在fcitx5-rime的 derivation 中 看到),我们加上了 rime-japanese 包,使得 rime 引擎能找到相关配置文件。

进入正题:如果 rime-japanese 是我们自己写的 derivation,要如何激活它呢? 通常做法是使用callPackage函数。我们可以做一个实验:把上文中所展示的 derivation 复制到一个 test.nix 文件中,将这个文件放到 NixOS 的配置文件 所在的文件夹中,然后在配置文件中写这样一段代码:

  i18n.inputMethod.fcitx5.addons = [
    (pkgs.fcitx5-rime.override {
      rimeDataPkgs = [
        pkgs.rime-data
        (pkgs.callPackage ./test.nix { })
      ];
    })
  ];

这样就激活了我们复制下来的 rime-japanese derivation 了!

callPackage函数接收第一个参数是 derivation 的文件路径,第二个参数是 传给 derivation 的参数,这里是一个空属性集;这是因为callPackage会自 动注入类似libstdenvNoCC之类的参数,所以不用手动指定了;当然手动 指定有更高的优先级,并且对于那些callPackage不能自动注入的参数,必须 手动指定(至于到底能自动注入哪些参数,或者说一个 derivation 究竟需要什 么参数,我的建议是仿照别人写的 derivation,看看他们使用了什么参数)。

下一节我们将尝试自己写一个 derivation。

1 个赞
  • 编写自己的 derivation:从 holo-layer 入手

如果说前面的内容都只是理论,只是基础的背景知识,那么从现在开始,我们就 要开始实践了!

考虑到 EAF 太大、太复杂,我们不妨从懒猫大佬的另一个作品 holo-layer 入 手(主要是我觉得那个果冻光标效果挺好的,一直在用)。懒猫大佬写的大部分 插件使用的架构和 EAF是几乎一样的,即 Emacs Lisp 和 Python 配合,因此很 有参考价值。

这个包不需要 gcc 等工具,可以选择 stdenvNoCC 环境。其实 Nix 还提供了许 多专用的环境,比如 emacsPackages 中有 trivialBuild、elpaBuild 等环境, 要构建 Python 库也有 buildPythonPackage 环境;但为了简单,我们在这里还 是选择 stdenvNoCC 环境。注:emacsPackages 中的环境默认会对 elisp 进行 编译,最终会得到 native-compile 的 .eln 文件,运行速度快;但是 EAF 和 holo-layer 不知道是特性还是 bug,总是编译不通过(恼)。

仿照 rime-japanese 我们马上就可以写出这样一个模板:

{
  stdenvNoCC,
  fetchFromGitHub,
  ... # 这三个点代表传进来的属性集可以有其他多余的字段
}:

stdenvNoCC.mkDerivation rec {

  pname = "holo-layer";
  version = "20250405.0";

  src = fetchFromGitHub {
    owner = "manateelazycat";
    repo = "holo-layer";
    rev = "d99a7e2f21eeed74eafe491d5a33c861fb1f879c";
    hash = "sha256-8wNq9a7yNbpkuOH7oMRO1kbdAQb87HXeMHvOamYSwyQ=";
  };

  installPhase = ''
    runHook preInstall

    ???

    runHook postInstall
  '';

}

“安装”步骤要安装什么文件呢?首先分析一下 holo-layer 中的文件,去除效果 图、README 之类的东西,留下运行时必不可少的东西。我感觉需要安装的有根 目录下的“.el”、“.py”文件、icon_cache 中的“.png”图片、plugin 中的“.py” 文件、resources 中的文件、swaymsg-treefetch 中的“.sh”文件。(其实直接 安装所有文件也不是不行)

要安装到什么地方呢?可以找一个的 emacsPackages 中现有的一个插件参考一 下,例如我们可以在/nix/store/中搜索 magit,会找到一些“.drv”文件和一 些文件夹,先不管“.drv”文件,找一个名称类似 /nix/store/8vy0b3x4cqcyz7pdicwhld9yb7i69wp3-emacs-magit-20250528.1146/ 的文件夹看看,先不管编译出的“.elc”、“.eln”文件,找“.el”源码,发现都在 share/emacs/site-lisp/elpa/magit-20250528.1146目录下,那么我们就能写 出这样的 bash 脚本:

  installPhase = ''
    runHook preInstall

    LISPDIR=$out/share/emacs/site-lisp/trivial/${pname}-${version}
    install -D *.el -t $LISPDIR/
    install -D *.py -t $LISPDIR/
    install -D icon_cache/*.png -t $LISPDIR/icon_cache/
    install -D plugin/*.py -t $LISPDIR/plugin/
    install -D resources/kdic-ec-11w.* -t $LISPDIR/resources/
    install -D swaymsg-treefetch/*.sh -t $LISPDIR/swaymsg-treefetch/

    runHook postInstall
  '';

这里我没用 elpa 路径,而是用 trivial 路径以示区分(实践证明这么做是可 以的,至于为什么可以,之后再说),然后这里的 pname 和 version 是之前在 属性集中定义的字段,会自动转换为环境变量(注意属性集前加了 rec 标记, 这样才能在属性集中引用自身的其他属性)。

如前文所述,用withPackages配合callPackage调用这个 derivation,理论 上就能正常地构建这个包了。打开 Emacs,应该就能通过requireuse-package调用了。但是我们还没有配置 Python 环境,它根本运行不起来, 在下一节中,我们将解决 Python 环境问题。

3 个赞
  • 为 holo-layer 添加 Python 环境

虽然 holo-layer 的 README 上说使用 pip 安装 Python 依赖库,但是我们用 的是声明式的 NixOS,像 pip 这种东西那是看不上一点,我们要用 Nix 来解决 依赖。

我们可以通过给系统加一个 Python,并对这个它使用 withPackages 增加必须 的 Python 库来解决问题。我们先尝试这个方法。

在 holo-layer 的 README 中找到其所需的 Python 依赖,是这样的: pip3 install epc sexpdata six inflect PyQt6 PyQt6-Qt6 PyQt6-sip

接下来就是上 NixOS 官网查有没有这些库的包了,查到的包名称的格式是类似 这样的:python313Packages.pyqt6,这里“313”指 Python 的 3.13 版本。最终 我们找到了除 PyQt6-Qt6 之外的其他包,我们怀疑 PyQt6-Qt6 可能被包括在 pyqt6这个包中了,所以先不管它。

然后就是往系统中添加 Python 了,我们可以直接选择非常新的 Python 3.13 版本,将如下代码加入environment.systemPackages中:

(pkgs.python313.withPackages
  ps: with ps; [
    epc
    inflect
    pyqt6
    pyqt6-sip
    sexpdata
    six
  ]
)

这里用到了 Nix 的with语句。with语句在放在列表前,作用是给列表的每 一项加上前缀,例如with pkgs; [ emacs firefox ],就是把 pkgs 作为前缀 加到 emacs 和 firefox 上,结果等于[ pkgs.emacs pkgs.firefox ]

完成所有操作之后,我们打开 Emacs,加载 holo-layer,结果发现还有报错, 缺少一个叫 Xlib 的 Python 库,好在官网能查到它的包,直接加进去,变成这 样:

(pkgs.python313.withPackages
  ps: with ps; [
    epc
    inflect
    pyqt6
    pyqt6-sip
    sexpdata
    six
    xlib
  ]
)

再次尝试,发现终于成功了,可以使用果冻光标了(喜)。

但这种方法并不优雅,我们的包应该是 standalone 的,不应该依赖系统安装的 Python 环境,下一节我们将解决这个问题。

2 个赞

手动点赞

eaf用nix管理之前查了好久,最后用这里面的方法成功了。但是还是有点问题,nix flake update以后要手动替换掉 npmDepsHash

3 个赞
  • 将 Python 环境集成到 holo-layer 包中

在 Nix 的各种 mkDerivation 函数中,有几个字段是用来处理外部依赖的,其 中最基础的就是 buildInputs 字段。这个字段的值应该是一个列表,列表的每 一项都应该是一个 derivation。Nix 会先构建 buildInputs 中的 derivation, 再去构建我们写的 derivation。在构建我们的 derivation 时,可以使用 buildInputs 中的derivation(例如将编译器放到 buildInputs 中,就可以在 构建时用它来编译我们的源码了);并且构建完成后,我们的程序也可以直接使 用 buildInputs 中的 derivation(例如使用python3命令调用 Python 解释 器)。

在正式开始之前,先介绍一下 Nix 中的 let 语法,举个例子:

let

  a = 1;
  b = a + 1;

in

{
  item1 = a;
  item2 = b;
}

可以认为我们在letin之间写了一个没有大括号的属性集,而在in后面 的属性集中,我们可以直接使用let中的键,它会被替换为对应的值,所以上 面的代码等价于{ item1 = 1; item2 = 2; }in后面除了跟一个属性集, 还可以跟一个列表,或者直接跟一个let中的键,效果相同。

使用 let 语句来将 Python 环境加到 我们的 derivation 的 buildInputs 中, 效果大概是这样的:

{

  stdenvNoCC,
  fetchFromGitHub,

  python3,
  ...
}:

let

  python-pkgs =
    ps: with ps; [
      epc
      inflect
      pyqt6
      pyqt6-sip
      sexpdata
      six
      xlib
    ];
  python-env = python3.withPackages python-pkgs;

in

stdenvNoCC.mkDerivation rec {

  ### 省略

  buildInputs = [ python-env ];

  ### 省略

}

我们把 Python 的依赖库列表(虽然在这里,它形式上是一个抽象而不是列表, 但还是叫依赖库列表比较符合习惯)单独拆为python-pkgs字段,在 withPackages后面使用这个字段,使得代码架构更加清晰。注意在这里我们引 入了型参python3,这也是callPackage能自动注入的参数之一。我们将添加 好了各种库的完整 Python 环境用字段python-env表示,最后用buildInputs = [ python-env ];指定其为 buildInputs。

当我们完成这一切操作,并且将系统的 Python 环境移除,兴致勃勃地进行测试 的时候,我们就会发现这根本没用:holo-layer 还是找不到 Python(悲)。

让我们分析一下:设置 buildInputs 相当于把我们的 Python 环境中的那些可 执行的文件——例如python3——所在的路径放到环境变量 PATH 中,但是这只对 我们自己写的 holo-layer 包起效果。事实上,是 Emacs 读取 holo-layer 中 的 elisp 脚本,由 Emacs 去调用python3,而 Emacs 包的 PATH 并没有受影 响,自然找不到 Python。

去修改 Emacs 包的 PATH 也许并不是一个好主意,因为将来其他的包,比如我 们的 EAF,也会构建一个的 Python 环境,其中的依赖库是不同的。我们可以选 择构建一个 Python 环境包含所有的依赖库,但是那操作起来十分麻烦,感觉不 是很优雅。我们也不用担心两个分开的 Python 环境会占用很多空间,因为如果 有相同的依赖库,Nix 只会构建一次,然后把它软链接到不同的环境。

那么我提出了什么解决方法呢?答案是打补丁。不要忘了前面说的 Nix derivation 的构建流程中是有“打补丁”这么一步的。只要我们将 holo-layer中 调用python3命令的相关 elisp 代码,替换为 python3 的绝对路径,那么 Emacs 就一定可以找到。我的实现方法如下:

  patchPhase = ''
    substituteInPlace holo-layer.el \
      --replace "\"python3\"" \
                "\"${python-env.interpreter}\""
  '';

查看 holo-layer 的 elisp 代码,发现调用 Python 的命令是由变量 holo-layer-python-command控制的,在这个变量的定义处,可以看到其在 Linux 平台下的默认值是"python3",只需要把它改为绝对路径即可。因此我 们指定 patchPhase 字段,它的值也应该是一段 bash 脚本——和 installPhase 一样,substituteInPlace好像是 Nix 的特殊命令,它原地修改一个文件,这 里我们指定 holo-layer.el 文件,然后调用--replace替换操作,匹配文件中 的"python3"字符串(带双引号),替换为绝对路径(带双引号)。

如果你还记得前面的知识,就会知到我们在外边定义的 python-env 字段,会自 动变成 bash 中的环境变量,因此可以用${python-env}调用。而后面的 interpreter字段则是该环境中的 Python 解释器的绝对路径,是预定义好的; 但如果我们不知道这个字段,还有另一个办法:python-env 字段的值是我们搭 建的 Python 环境,是一个 derivation,它是可以自动转换为字符串的,结果 为最终仓库的绝对路径;因此在它后面拼接上/bin/python3就得到了 python3命令的绝对路径。

再次测试,发现终于成功了。我们最终完成了一个 standalone 的、含有 Python 依赖的 Emacs 插件的 derivation 的编写,可喜可贺,可喜可贺。

在下一节,我们将开始讨论如何为 Emacs Application Framework 写 derivation。

6 个赞

为您研究精神点赞,无脑点赞,太厉害了。

我只能说你想的挺天真

lz写的太好了

“到底能自动注入哪些参数,或者说一个 derivation 究竟需要什 么参数”,一直想知道这两个问题的答案,但是好像没有什么统一的文档把这个说的挺别明白的 :smiling_face_with_tear:

好兄弟问一下,aria2 service 有个功能是在下载 开始/完成 等事件发生的时候运行外部脚本,我想让 aria2 可以运行附带第三方库 aria2p 的 python:(python312.withPackages (ps: [ ps.aria2p ])), configuration.nix 要怎么写呢,主要困惑的点是

  1. aria2 怎么找到 python 且能 import aria2p
  2. aria2 有权限运行 python 吗

nixpkgs aria2 service

aria2 该参数相关文档

python3.withPackages ( ... )放到environment.systemPackages中,这样python以及python3命令就全局可用了,并且已经带上了那些包,可以直接import

权限啥的我不清楚,你要尝试一下才知道。

不过我看那个文档里面写传入的参数是一个脚本文件吧,我不知道 aria2p 的接口是什么样的,也许你可以写一个 .py 脚本去调用 aria2p。

1 个赞

好好好感谢