【经验分享】我如何学习写 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 方法。本人资历尚浅,提出的方法未必是(大概率不是) “最佳实践”,也无法保证没有知识性错误,还请各位批判性地阅读。

参考文献/推荐阅读:

7 个赞
  • 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。