问题
在编写NixOS模块的过程中,我们经常会遇到需要多次判断某个复杂条件(即不是简单的、由一个选项决定的条件)来决定是否做一些操作的问题。
比如假设我们有一个gui模块开关选项 options.gui.enable,其下有一个zen浏览器子模块开关选项 options.gui.zen.enable。
我们希望当用户设置 config.gui.enable = false 时,不管如何设置 config.gui.zen.enable,zen浏览器都不会被启用。
因为Nix并不存在真正的子选项,options.gui.enable 与 options.gui.zen.enable 实际上是两个平行选项,并不会相互影响。
所以如果要在配置里实现上述效果,传统的做法是用一个带 && 的表达式来做判断,就像这样:
1 | config = lib.mkIf config.gui.enable && config.gui.zen.enable { |
如果zen浏览器模块是单个文件,这么做没有什么问题。
但如果zen浏览器模块的内容越来越多,以至于被拆分成了很多文件,然后每个文件里都要写这么一个判断,那就简直太地狱了。
另外一种简单的解决方式是只 mkIf config.gui.zen.enable,然后添加assertion,让zen启用但gui未启用时直接报错。
但是这样用户就必须额外更改配置,用户体验很不好。
什么是中间选项
中间选项即,我们可以创建一个选项,它只读、值由现有选项的值决定、不在文档中显示、也不能且不应被用户设置。
NixOS模块系统给予了我们创建这种选项的能力,我们只需要像如下这样使用 mkOption,即可创建这样的选项:
1 | mkOption { |
为了更方便地创建这种选项,我们还可以创建一个工具函数:
1 | mkComputedOption = by: lib.mkOption { |
当然我们也可以给这种选项设置 type,使其值被模块系统类型检查,这里不再赘述。
中间选项如何解决上面的问题
我们可以创建一个 options.internal.final.gui.zen.enable 的中间选项,其值为用户设置的 config.gui.enable 和 config.gui.zen.enable 的与运算:
1 | options.internal.final.gui.zen.enable = mkComputedOption (config.gui.enable && config.gui.zen.enable); |
这里我们把这个中间选项放置在 internal.final 中,表示这是一个内部的、不应被手动设置的选项,且其是一个计算后的最终选项。
当然,命名空间不是强制的,只是一种约定,可以让我们看到选项名就知道这个选项大概是什么类型,减少误用。
之后不管zen浏览器分出了多少模块,我们都只需要 mkIf config.internal.final.gui.zen.enable,再也不用到处写带括号的与运算表达式了(这里顺便吐槽一下Nix的运算符优先级,写好多东西都要加括号)。
叠层?嵌套?
是的,中间选项时可以嵌套的,这也并不会导致无限递归,毕竟它很trivial。
比如我们可以这样:
1 | options.internal.gui.enable = mkComputedOption (config.gui.niri.enable || config.gui.hyprland.enable); |
更高级(抽象)的用法——由home-manager配置决定系统配置
假设我们的模块化做得非常好,每个主机配置都可以有不同的用户,且每个用户都可以选择开关home-manager模块中的选项。
比如主机A上的用户a可以开启gui模块,但用户b则关闭gui模块。
在这种情况下,就有可能出现一台主机上没有一个用户开启gui模块的情况,而这时,再无条件把gui模块放进系统闭包就是useless的,只会增加闭包体积。
最简单的解决方式是,我们在NixOS模块中创建需要单独设置的gui模块开关,然后在home-manager模块中做assertion,使得只要有一个用户启用了gui模块但主机没有启用gui模块就报错。
但这也和文章开头的「解决方案」有相同的问题,就是增加了用户的设置复杂度。
因此,我们可以创建一个中间选项,其值在「任意一个用户启用了gui模块」时为真:
这里我们假设我们定义的NixOS模块都在os命名空间下,home-manager模块都在home命名空间下,以方便阅读,即使它们实际上并不会冲突。
1 | options.os.internal.gui.enable = mkComputedOption ( |
这样我们就可以实现「主机上任意用户启用gui模块,则主机启用gui模块」了,对于没有任何启用gui模块的用户的主机,gui模块是关闭的,闭包可以小很多。
是的,这也并不会无限递归,lazyness,很神奇吧。
后记
我们通过巧妙地使用NixOS模块系统提供的 internal 和 readOnly 属性,构建了一种中间选项,其可以显著地帮助我们降低配置的复杂性。
当然,这里因为篇幅原因,使用的都是高度简化的案例,若有兴趣,可查看 我的NixOS配置,其中大量使用了本文讲述的中间选项,可以说是很落地实践了。
