Skip to content

模块系统与自定义 options

我们在前面的 NixOS 配置中通过设置各种 options 的值来配置 NixOS 或者 Home Manager,这些 options 实际都在这两个位置定义:

如果你还使用 nix-darwin,那么它的配置也是类似的,其模块系统的实现位于 nix-darwin/modules

而上述 NixOS Modules 跟 Home Manager Modules 的基础,是 Nixpkgs 中实现的一套通用模块系统 lib/modules.nix,这套模块系统的官方文档如下(即使是对熟练使用 NixOS 的用户而言,要看懂这玩意儿也不是件容易的事...):

因为 Nixpkgs 的模块系统文档没人写,文档中直接建议读另一份专门针对 NixOS 模块系统的编写指南,确实写得清晰一些,但也很难说它对新手有多友好:

总之,模块系统是由 Nixpkgs 实现的,并不是 Nix 包管理器的一部分,因此它的文档也不在 Nix 包管理器的文档中。另外 NixOS 与 Home Manager 都是基于 Nixpkgs 的模块系统实现的。

模块系统有什么用?

我们作为一个普通用户,使用 NixOS 与 Home Manager 基于模块系统实现的各种 options 就已经能满足我们大部分的需求了。那么深入学习模块系统对于我们来说,还有什么好处呢?

我们在前面介绍配置的模块化时,提到了核心点是将配置拆分为多个模块,再通过 imports = [ ... ]; 来导入这些模块。这其实就是模块系统最基础的用法。但仅仅使用imports = [ ... ];,我们只能将模块中定义的配置原封不动地导入到当前模块中,无法对其做任何定制,灵活性很差。在配置简单的情况下,这种方式已经足够了,但如果我们的配置比较复杂,那么这种方式就显得力不从心了。

这里举个例子来说明其弊端,譬如说我通过一份配置管理了 A、B、C 跟 D 共 4 台 NixOS 主机,我希望能在尽量减少配置重复的前提下实现如下功能:

  • A、B、C 跟 D 都需要启用 docker 服务,设置开机自启
  • A 需要将 docker 的存储驱动改为 btrfs,其他不变
  • B、C 是位于中国的服务器,需要在 docker 配置中设置国内镜像源
  • C 是位于美国的服务器,无特殊要求
  • D 是桌面主机,需要为 docker 设置 HTTP 代理加速下载

如果单纯使用 imports,那么我们可能得将配置拆分成如下几个模块,然后在每台主机上导入不同的模块:

bash
 tree
.
├── docker-default.nix  # 基础的 docker 配置,包含开机自启
├── docker-btrfs.nix    # 导入了 docker-default.nix,将存储驱动改为 btrfs
├── docker-china.nix    # 导入了 docker-default.nix,设置国内镜像源
└── docker-proxy.nix    # 导入了 docker-default.nix,设置 HTTP 代理

是否感觉到这样的配置很冗余?这还是一个简单的例子,如果我们的机器更多,不同机器的配置差异更大,那么这种配置的冗余性就会更加明显。

显然,我们需要借助其他的手段来解决这个配置冗余的问题,自定义一些我们自己的 options 就是一个很不错的选择。

在深入学习模块系统之前,我再强调一下,如下内容不是必须学习与使用的,有很多 NixOS 用户并未自定义任何 options,只是简单地使用 imports 就能满足他们的需求了。如果你是新手,可以考虑在遇到类似上面这种,imports 解决不了的问题时再来学习这部分内容,这是完全 OK 的。

基本结构与用法

Nixpkgs 中定义的模块,其基本结构如下:

nix
{ config, pkgs, ... }:

{
  imports =
    [ # import other modules here
    ];

  options = {
    # ...
  };

  config = {
    # ...
  };
}

其中的 imports = [ ... ]; 我们已经很熟悉了,但另外两个部分,我们还没有接触过,这里简单介绍下:

  • options = { ... };: 它类似编程语言中的变量声明,用于声明一些可配置的选项。
  • config = { ... };: 它类似编程语言中的变量赋值,用于为 options 中声明的选项赋值。

最典型的用法是:在同一 Nixpkgs 模块中,依据 options = { ... }; 中声明的 options 当前的值,在 config = { .. }; 中为其他的 options 赋值,这样就实现了参数化配置的功能。

直接看个例子更容易理解:

nix
# ./foo.nix
{ config, lib, pkgs, ... }:

with lib;

let
  cfg = config.programs.foo;
in {
  options.programs.foo = {
    enable = mkEnableOption "the foo program";

    package = mkOption {
      type = types.package;
      default = pkgs.hello;
      defaultText = literalExpression "pkgs.hello";
      description = "foo package to use.";
    };

    extraConfig = mkOption {
      default = "";
      example = ''
        foo bar
      '';
      type = types.lines;
      description = ''
        Extra settings for foo.
      '';
    };
  };

  config = mkIf cfg.enable {
    home.packages = [ cfg.package ];
    xdg.configFile."foo/foorc" = mkIf (cfg.extraConfig != "") {
      text = ''
        # Generated by Home Manager.

        ${cfg.extraConfig}
      '';
    };
  };
}

上面这个模块定义了三个 options

  • programs.foo.enable: 用于控制是否启用此模块
  • programs.foo.package: 用于自定义 foo 这个包,比如说使用不同版本、设置不同编译参数等等。
  • programs.foo.extraConfig: 用于自定义 foo 的配置文件。

然后在 config 中,根据 options 中声明的这三个变量的值,做了不同的设置:

  • 如果 programs.foo.enablefalse 或者未定义,则不做任何设置。
    • 这是借助 lib.mkIf 实现的。
  • 否则
    • programs.foo.package 添加到 home.packages 中,以将其安装到用户环境中。
    • programs.foo.extraConfig 的值写入到 ~/.config/foo/foorc 中。

这样,我们就可以在另一个 nix 文件中导入这个模块,并通过设置这里定义的 options 来实现对 foo 的自定义配置了,示例:

nix
# ./bar.nix
{ config, lib, pkgs, ... }:

{
  imports = [
    ./foo.nix
  ];

  programs.foo ={
    enable = true;
    package = pkgs.hello;
    extraConfig = ''
      foo baz
    '';
  };
}

上面这个例子中我们为 options 赋值的方式实际上是一种缩写,当一个模块中只声明了options,而没有声明 config (以及其他模块系统的特殊参数)时,我们可以省略掉 config 前缀,直接使用 options 的名称进行赋值。

模块系统的赋值与延迟求值

模块系统充分利用了 Nix 的延迟求值特性,这也是它能实现参数化配置的关键。

先看个简单的例子:

nix
# ./flake.nix
{
  description = "NixOS Flake for Test";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";

  outputs = {nixpkgs, ...}: {
    nixosConfigurations = {
      "test" = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ({config, lib, ...}: {
            options = {
              foo = lib.mkOption {
                default = false;
                type = lib.types.bool;
              };
            };

            # 示例 1(正常)
            config.warnings = if config.foo then ["foo"] else [];

            # 示例 2(无限递归)
            #   error: infinite recursion encountered
            # config = if config.foo then { warnings = ["foo"];} else {};

            # 示例 3(正常)
            # config = lib.mkIf config.foo {warnings = ["foo"];};
          })
        ];
      };
    };
  };
}

上述配置中的示例 1、2、3 中,config.warnings 的值都依赖于 config.foo 的值,但它们的实现方式却不同。将上述配置保存为 flake.nix,然后使用命令 nix eval .#nixosConfigurations.test.config.warnings 分别测试示例 1、2、3,可以发现示例 1、3 都能正常工作,而示例 2 则会报错 error: infinite recursion encountered

下面分别解释说明下:

  1. 示例一计算流程:config.warnings => config.foo => config
    1. 首先,Nix 尝试计算 config.warnings 的值,但发现它依赖于 config.foo.
    2. 接着,Nix 尝试计算 config.foo 的值,它依赖于其外层的 config.
    3. Nix 尝试计算 config 的值,config 中未被 config.foo 真正使用的内容都会被 Nix 延迟求值,因此这里不会递归依赖 config.warnings
    4. config.foo 求值结束,接着 config.warnings 被赋值,计算结束。
  2. 示例二:config => config.foo => config
    1. 首先,Nix 尝试计算 config 的值,但发现它依赖于 config.foo.
    2. 接着,Nix 尝试计算 config.foo 的值,它依赖于其外层的 config.
    3. Nix 尝试计算 config 的值,这又跳转到步骤 1,于是进入无限递归,最终报错。
  3. 示例三:跟示例二唯一的区别是改用了 lib.mkIf 解决了无限递归问题。

其关键就在于 lib.mkIf 这个函数,使用它定义的 config 会被 Nix 延迟求值,也就是说会在 config.foo 求值结束后,才会真正计算 config = lib.mkIf ... 的值。

Nixpkgs 中的模块系统提供了一系列类似 lib.mkIf 的函数,用于实现参数化配置与智能的模块合并:

  1. lib.mkIf: 上面已经介绍过了。
  2. lib.mkOverride / lib.mkdDefault / lib.mkForce: 在前面模块化 NixOS 配置 中已经介绍过了。
  3. lib.mkOrder, lib.mkBeforelib.mkAfter: 同上
  4. 查看 Option Definitions - NixOS 了解更多与 options 赋值(definition)相关的函数。

Options 声明与类型检查

模块系统的赋值是我们最常用的功能,而如果我们需要自定义一些 options,还需要深入了解下 options 的声明与类型检查。

这个我觉得就还挺简单的,比赋值要简单挺多了,直接看官方文档就能懂个大概,这里就不再赘述了:

传递非默认参数到模块系统中

我们在使用 Flakes 来管理你的 NixOS 中已经介绍了如何使用 specialArgs_module.args 来传递额外的参数给其他 Modules 函数,这里不再赘述。

如何选择性地导入模块

在上面的例子中,我们已经介绍了如何通过自定义的 options 来决定是否启用某个功能,但我们的代码实现都是在同一个 nix 文件中的,那么如果我们的模块是分散在不同的文件中的,该如何实现呢?

我们先来看看一些常见的错误用法,然后再来介绍正确的使用方式。

错误用法一 - 在 config = { ... }; 中使用 imports

你最先想到的,大概是直接在 config = { ... }; 中使用 imports,类似这样:

nix
# ./flake.nix
{
  description = "NixOS Flake for Test";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";

  outputs = {nixpkgs, ...}: {
    nixosConfigurations = {
      "test" = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ({config, lib, ...}: {
            options = {
              foo = lib.mkOption {
                default = false;
                type = lib.types.bool;
              };
            };

            config = lib.mkIf config.foo {
              # 在 config 中使用 imports 会报错
              imports = [
                {warnings = ["foo"];}

                # ...省略其他模块或文件路径
              ];
            };
          })
        ];
      };
    };
  };
}

但这样是行不通的。你可以尝试使用上述 flake.nix 运行nix eval .#nixosConfigurations.test.config.warnings,会遇到报错 error: The option 'imports' does not exist.

这是因为 config 是一个普通的 attribute set,而 imports 是模块系统的特殊参数。并不存在 config.imports 这样的 options 定义。

正确用法一 - 为所有需要条件导入的模块定义各自的 options

这是最推荐的方式。NixOS 系统中的模块都是这样实现的,在 https://search.nixos.org/options 中搜索 enable 能看到非常多的可通过 enable option 启用或关闭的系统模块。

具体的写法已经在前面的 基本结构与用法 中介绍过了,这里不再赘述。

它的缺点是,所有需要条件导入的 Nix 模块都要做改造,把其中的配置声明全部移到 config = { ...}; 代码块中,代码复杂度会增加,同时也对新手不太友好。

正确用法二 - 在 imports = []; 中使用 lib.optionals

这种方式的主要好处是,它要比前面介绍的方法简单许多,不需要对模块内容做任何修改,只需要在 imports 中使用 lib.optionals 来决定是否导入某个模块即可。

lib.optionals 函数的详细文档: https://noogle.dev/f/lib/optionals

直接看例子:

nix
# ./flake.nix
{
  description = "NixOS Flake for Test";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";

  outputs = {nixpkgs, ...}: {
    nixosConfigurations = {
      "test" = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        specialArgs = { enableFoo = true; };
        modules = [
          ({config, lib, enableFoo ? false, ...}: {
            imports =
              [
                 # 这里写其他模块
              ]
              # 通过 lib.optionals 来决定是否foo.nix
              ++ (lib.optionals (enableFoo) [./foo.nix]);
          })
        ];
      };
    };
  };
}
nix
# ./foo.nix
{ warnings = ["foo"];}

将上述两个 nix 文件保存到一个文件夹中,然后在文件夹中运行nix eval .#nixosConfigurations.test.config.warnings,运行正常:

bash
 nix eval .#nixosConfigurations.test.config.warnings
[ "foo" ]

这里需要注意的一点是,不能在 imports =[ ... ]; 中使用由 _module.args 传递的参数,我们在前面传递非默认参数到模块系统中 一章中已经做过详细说明。

References