缘起#
把配置拆成公共模块和每台电脑的文件之后(见第三篇),很快就会撞上一个问题:两个文件都设置了同一个选项,到底谁说了算?
我自己就被这个问题坑过:公共配置里写死了时区是UTC,新买的笔记本配置里又单独设了一个本地时区,结果一跑nixos-rebuild直接报冲突。上网一搜,到处都是"加个mkDefault就行"“用mkForce压过去"之类的回答,抄完确实不报错了,但我当时根本没搞懂这两个东西为什么能解决问题,也不知道什么时候该用哪个。
这篇就把这背后的道理理清楚:配置是怎么合并的,mkDefault、mkForce这些到底在做什么,最后再写一个带开关的小模块练练手。下面的文件名、主机名都是我为了演示现编的,可以直接照着搭个测试配置试一遍。
一个配置文件就是一个模块#
NixOS模块不一定很复杂。平时编辑的configuration.nix就是模块,把一组相关设置放到另外一个文件里,也可以成为模块。
例如,建立modules/common.nix:
| |
这里设置时区,并安装tree和jq。第一行表示这个文件是一个函数,接收包含pkgs的参数集合,返回下面的配置。pkgs.tree和pkgs.jq就是从软件包集合中取出这两个包;...表示还可以接收其他参数,不需要全部列出来。
经常看到的参数还有lib和config。lib提供mkDefault等辅助函数,config则可以读取各个模块合并后的配置。它们由模块系统传入,通常不用自己调用这个函数。
如果文件中没有用到这些参数,也可以直接写一个属性集,例如{ time.timeZone = "UTC"; }。这里说的模块是NixOS配置模块,和内核驱动模块不是同一个概念。
用imports把文件接起来#
假设主机配置位于hosts/laptop/configuration.nix,可以这样导入公共文件:
| |
这是主机模块的节选,原有的启动、用户等配置仍要保留。路径相对于写着imports的这个文件,所以这里需要向上两层才能找到modules目录。
如果沿用第三篇的mkHost函数,已经在flake的modules列表里加入了公共模块,就不用再在这里导入它。这是两种组织方法,选一种容易看懂的就好。
文件放进目录并不会自动生效,需要从当前主机的模块列表或imports一路连接到它。对于Git管理的本地flake,新文件还要先用git add加入索引,Nix才看得到。
这里的imports也不是Shell脚本那样"先执行公共配置,再执行主机配置”。模块系统收集这些文件中的定义,再根据选项的类型和优先级合并。因此,把某个文件移到列表末尾,并不等于让它覆盖前面的设置。我第一次踩这个坑时就是以为调整顺序能解决冲突,结果折腾了半天才发现顺序根本不是重点。
Nix语言另外还有一个import函数,用来读取并求值Nix文件。它与模块里的imports列表名字相似,但不会单独替我们完成NixOS选项的合并。
同一个选项写两次会怎样#
软件包列表可以合并#
上面的两个文件分别设置了environment.systemPackages。这个选项是列表类型,两边的定义会合并,所以公共配置里的tree、jq和主机配置里的ripgrep都会加入系统软件包列表。完整列表还可能包含其他模块提供的包。
这里不需要在主机配置中写成:
| |
这个写法是一个反例:config.environment.systemPackages已经是合并后的最终值,而我们正在定义这个值,再引用它来拼接就会造成递归。直接定义本模块需要添加的列表就可以了。
两个不同的时区会冲突#
现在假设公共配置仍然写着"UTC",又在主机模块中加入:
| |
两个普通定义的优先级相同,而时区不能同时是两个不同的字符串,评估时就会报冲突。调整imports顺序解决不了这个问题——这就是我前面提到的那个坑。
这里需要区分选项声明和选项定义:声明规定选项叫什么、接受什么类型、默认值是什么;定义则是我们平时写的time.timeZone = "UTC";,为它提供一个值。许多常用选项已经由NixOS声明好了,使用时只需要定义它们。
具体怎样合并由选项类型决定。列表通常拼接,普通字符串通常要求同一优先级的值一致,也有专门用于拼接文本行的字符串类型。遇到冲突时,我现在会先去查选项说明,而不是照着值的外观瞎猜规则。
公共设置可以用mkDefault#
如果想表达"通常使用UTC,但每台电脑可以自己选择",就把modules/common.nix改成:
| |
这里比原来多接收了lib参数,并给时区的值加上lib.mkDefault。主机里的time.timeZone = "Europe/Paris";不用改,它的普通定义会胜出。没有另外设置时区的主机,则继续使用公共配置中的UTC。这正是我当初那个冲突最后的修法。
常见的定义优先级可以放在一起看:
| 写法 | 优先级数值 | 用途 |
|---|---|---|
mkOption中的default | 1500 | 声明选项时提供的默认值 |
lib.mkDefault value | 1000 | 在配置中提供容易覆盖的设置 |
普通的option = value; | 100 | 一般配置 |
lib.mkForce value | 50 | 覆盖上面几类定义 |
数值越小,优先级越高。模块系统先留下优先级最高的定义,再按选项类型合并它们。官方手册的优先级说明介绍了这些规则。
两个文件都用mkDefault给时区设置不同的值,仍然会冲突——mkDefault不是"遇到其他值就无条件退让",它自己也是有优先级的,只是比普通定义低一级。
什么时候用mkForce#
如果真的需要覆盖别的模块里的普通定义,可以写成time.timeZone = lib.mkForce "Europe/Paris";。不过在这个例子里,其实不用加mkForce——把公共配置改成mkDefault就够了,没必要在主机这边硬压。
我现在看AI改配置时,第一反应是去查它是不是偷懒直接甩了个mkForce上去——这是最快但也最糟的修法,报错是没了,但谁覆盖了谁、为什么会冲突,这些问题都被盖住了。我会先去翻两个定义是从哪来的:是本来就该有个默认值,还是哪个模块根本不该被导进来。后一种情况,把import关系理顺比留着mkForce好维护得多。
列表也要小心。普通定义和mkDefault列表同时存在时,默认列表会被整个舍弃,不是先拼接再覆盖;给整个environment.systemPackages加mkForce,也会把这个选项里优先级更低的其他定义一起丢掉,不是只替换某一个包。两个不同的mkForce定义碰到一起,同样不会自动分出胜负。
mkBefore和mkAfter调整顺序#
顺序也容易搞混。假设想把本机增加的软件放在普通列表定义之后,可以把主机中的包列表改为:
| |
这里同样只是相关部分的节选。mkBefore和mkAfter调整留下来的列表定义怎样排序,不改变前面说的覆盖优先级。其他普通列表定义仍然可以参与合并。
包列表这个例子方便观察顺序,实际上软件包很少需要特意排序;会按顺序执行的脚本片段才真正用得上它。它也解决不了软件包之间的文件冲突,别指望靠排序绕开那种问题。
写一个带开关的小模块#
普通配置拆成文件已经能解决很多问题,不需要每个文件都设计一套新选项。不过,如果一组设置经常一起启用,又有几个参数需要在不同电脑上调整,就可以给它做一个自己的开关。
这里沿用上一篇创建/etc文件的思路:启用后生成一个演示文件,内容由选项指定。新建modules/demo-notice.nix:
| |
demo.notice是这里自己起的名字,不是NixOS内置功能。options声明了两个选项:enable是默认关闭的布尔开关,message是带默认值的字符串。cfg只是一个局部简写,省得每次都写config.demo.notice。
下面的config描述启用后要做的事情:用已有的environment.etc选项生成文件。也就是说,自定义模块把我们新声明的选项转换成了NixOS已经认识的设置。
这里用了完整的options和config结构,就要把系统设置放进config中,不能再把environment.etc混写在最外层。前面那些只有设置的简短模块,则可以省略这层config。
然后在主机模块已有的imports列表中加入../../modules/demo-notice.nix,并在主机设置中添加:
| |
不要再写第二个同名的imports = ...;。同一个Nix属性集中重复定义一个属性,本身就是错误;前面说的合并,指的是不同模块提供的定义。
只导入模块但不开启时,它不会生成这个演示文件。启用后不写message,则使用Hello from NixOS。如果把message写成数字,评估这个选项时会遇到类型错误,这也是声明类型的用处。
为什么这里用mkIf#
cfg.enable来自最终配置,而这个模块也参与生成最终配置。如果直接写成config = if cfg.enable then { ... } else { };,模块系统在判断有哪些定义时,就可能需要先知道尚未确定的配置,造成无限递归。
lib.mkIf让模块系统把条件带到具体的选项定义上,再处理这些条件,这是我现在写带开关模块时固定会用的写法。同样,不要根据config.demo.notice.enable决定是否把这个模块放入imports——先导入模块,再用开关控制它提供的设置。mkIf也救不了循环依赖,让开关取决于它自己的相反值,照样没有合理结果。
不切换系统也能查看结果#
这些合并规则可以先通过评估观察,不必每次都运行switch。假设当前仓库已经提供laptop主机输出,先把新模块加入Git索引:
| |
按上面的设置,结果应为Hello from this computer。--raw直接输出字符串,不附加JSON引号,也不会自动补一个换行。
还可以查看将要生成的文件内容:
| |
它会输出同样的文字并带一个换行。不过这只是评估配置,当前电脑的/etc/nixos-module-demo还没有因此被创建。
时区也可以这样查:
| |
如果公共模块使用mkDefault "UTC",主机使用普通定义"Europe/Paris",这里应该得到Europe/Paris。
遇到意外结果时,先搜索自己的定义,再看错误信息指出的文件:
| |
想查看参与最终时区合并的定义,还可以运行:
| |
这里查询的是options里的信息,会列出定义来源和对应值。它不是所有被覆盖设置的完整历史,优先级较低而被舍弃的定义不一定出现在这里,所以还需要结合源码搜索。输出可能带本地路径,分享报错时可以先整理一下。
如果某个选项根本不存在,要检查拼写、模块是否导入,以及示例适用的Nixpkgs版本。NixOS选项搜索可以查看类型、默认值和声明位置,但网页选择的版本也要与自己的锁定输入相对应。
最后再按上一篇的流程检查并构建目标系统:
| |
单独查询一个选项,只会求值所需的部分,不能代替整个系统的检查。确认构建结果后,再决定是否应用;应用之后才用cat /etc/nixos-module-demo查看实际文件。
现在我看AI生成的配置时,会先想它为什么把这个设置放在这个文件里、为什么用了默认值、要不要加了个覆盖——这几条想清楚了,大半问题也就清楚了。下一篇接着聊装软件的选择:放进系统配置、交给Home Manager,还是只用临时Shell和项目开发环境。
