Nix 模块系统:从选项声明到地图 API 的组合接口
原作者:Daniel Baker(A basic module)、Silvan Mosberger(Module system deep dive);编辑:Valentin Gagarin,以及深入篇的 Alexander Groleau。本文合并翻译整理 nix.dev 的两篇课程与公开辅助脚本,完整保留从声明、类型、合并到嵌套子模块的学习路径。深入篇来自面向 Summer of Nix 2021 的模块演讲;本稿于 2026 年 10 月 5 日核对全文。
这是一篇模块系统教程,不是免费的地图服务部署方案。地图部分使用 Google Maps API,需要你自己的 API 密钥、相应 API 权限和计费设置。运行会把地名等请求数据发送给 Google,并可能产生费用。本轮没有使用密钥、提交位置、调用地图服务或构建运行 Nix 示例;配图为本地技术示意,不是新生成的地图截图。

一、模块声明接口,配置定义取值
课程先把模块写成接收属性集、返回属性集的函数。模块可声明允许出现的选项,也可为自己或其他模块声明的选项定义值。模块系统完成求值后,再得到由全部声明和定义共同构成的属性集。最小函数是 { ... }: { };本系列也会在子模块等位置使用属性集形式。省略号接受模块系统传入的其他参数,lib 则由模块系统提供。
先建立 options.nix,用 lib.mkOption 声明字符串选项:
{ lib, ... }:
{
options.name = lib.mkOption {
type = lib.types.str;
};
}
再建立 config.nix,在顶层 config 中给同一个选项赋值:
{ ... }:
{
config.name = "Boaty McBoatface";
}
声明与定义不需要放在同一个文件中。用 default.nix 指定参与合并的模块:
let
pkgs = import <nixpkgs> { };
result = pkgs.lib.evalModules {
modules = [ ./options.nix ./config.nix ];
};
in
result.config
lib.evalModules 接收模块列表,结果中 config 才是最终取值。课程的辅助求值命令如下:
nix-shell -p jq --run "nix-instantiate --eval --json --strict | jq"
课程预期得到 {"name":"Boaty McBoatface"}。未声明的定义和类型不匹配会导致模块系统报错。这是原文的预期输出,本文未运行命令。入门示例使用 <nixpkgs>,结果取决于本机 NIX_PATH;它不是固定版本的完整锁定方案。
二、让错误帮助界定选项类型
深入篇从空的 default.nix 开始,声明 scripts.output 为 lib.types.lines。该类型只接受字符串,同时规定多份定义按换行拼接:
{ lib, ... }:
{
options.scripts.output = lib.mkOption {
type = lib.types.lines;
};
}
接着用 eval.nix 求值:
let
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-23.11";
pkgs = import nixpkgs { config = {}; overlays = []; };
in
pkgs.lib.evalModules {
modules = [ ./default.nix ];
}
nix-instantiate --eval eval.nix -A config.scripts.output
此时选项只有声明,没有值;访问它会报“使用了未定义选项”。若再写 config.scripts.output = 42;,错误则转为类型不匹配:整数不是可以按换行拼接的字符串。课程让读者故意经历这两个错误,再把值改成命令字符串:
config.scripts.output = ''
./map.sh size=640x640 scale=2 | feh -
'';
命令通过地图脚本请求 640×640、scale=2 的地图,再交给图像查看器 feh。单纯把命令写对并不保证依赖存在,下一步用 Nix 包装它。
版本注:原文的深入篇仍使用 nixos-23.11 tarball,它是历史分支,且没有给内容哈希。本文保留它以对应课程,未宣称它是当前受支持的部署基线。实际复现应选择经过审查的固定提交和哈希;不要把可移动 URL 等同于精确锁定。
三、把脚本包装成带依赖的包
在 evalModules.modules 列表中加入一个模块,为其他模块提供 pkgs:
modules = [
({ config, ... }: { config._module.args = { inherit pkgs; }; })
./default.nix
];
原文指出,这套 _module.args 机制的文档分散在模块实现中。接着把 scripts.output 的类型从 lines 改为 package,以 pkgs.writeShellApplication 生成真正带运行依赖的脚本:
{ pkgs, lib, ... }:
{
options.scripts.output = lib.mkOption {
type = lib.types.package;
};
config.scripts.output = pkgs.writeShellApplication {
name = "map";
runtimeInputs = with pkgs; [ curl feh ];
text = ''
${./map.sh} size=640x640 scale=2 | feh -
'';
};
}
Nix 路径插值会把本地 map.sh 复制到 Nix store,包装器也位于 store。只能复制不含密钥的程序文件,不能把 API 密钥作为 Nix 字符串、路径内容或派生输入写入 store。构建与运行是两个步骤:
nix-build eval.nix -A config.scripts.output
./result/bin/map
原文还提供通过 entr 自动重复构建与运行的工作流:
nix-shell -p entr findutils bash --run \
"ls *.nix | \
entr -rs ' \
nix-build eval.nix -A config.scripts.output --no-out-link \
| xargs printf -- \"%s/bin/map\" \
| xargs bash \
' \
"
它列出当前目录的 .nix 文件,让 entr 监视变化,-r 在变化时终止前一次命令;每次构建不创建 result 链接,而是把输出 store 路径接上 /bin/map 后交给 bash。这个循环会在编辑时反复调用外部 API,可能重复计费;应限于独立、可信的练习目录,理解 glob 和 xargs 的文件名限制。本轮未启用此自动执行流程。
四、选项类型同时决定合并行为
把原来写死的请求参数提升为 requestParams,其类型为 listOf str。列表来自多个模块时会合并,因而各模块可以贡献自己的参数。lines 会拼接字符串;str 不会把不同字符串随意拼成一个新值,冲突定义会被拒绝。类型不仅是验证器,也是合并规则。
options.requestParams = lib.mkOption {
type = lib.types.listOf lib.types.str;
};
config.requestParams = [ "size=640x640" "scale=2" ];
在模块函数参数中加入 config,就可以让输出选项依赖其他选项:
{ pkgs, lib, config, ... }:
{
# 其他声明和定义略;这里展示替换后的 text 字段
config.scripts.output.text = "";
}
上面只是提醒函数参数需要包含 config,不能把包值当作普通脚本属性直接修改。真正的包装器仍由 writeShellApplication 构造,其 text 参数在原课程中改为:
text = ''
${./map.sh} ${lib.concatStringsSep " " config.requestParams} | feh -
'';
函数参数 config 是所有模块及 imports 求值合并后的配置;模块返回值里的 config 属性只提供本模块自己的定义。两者同名但不同层次。Nix 的惰性求值使模块能够引用最终配置,而不是只读取当前文件的局部赋值。
静态审查:这一版 requestParams 事实上包含将要解释的 shell 文本,不是天然安全的 argv 列表。concatStringsSep 不做 shell 转义。若允许不可信字符串直接进入此选项,就可能发生命令注入。对纯字面参数应使用 lib.escapeShellArgs 或运行时数组;但后文故意把地理编码命令替换嵌入参数,不能机械地对整个原始列表转义,否则会改变行为。课程应只处理可信模块定义,外部输入需经过独立安全边界。
五、用 null、默认值与条件定义表达自动行为
声明 map.zoom 为 nullOr int,允许整数或 null;允许 null 不代表它自动成为默认值,仍须显式设置 default:
options.map.zoom = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
};
config.requestParams = [
"size=640x640"
"scale=2"
(lib.mkIf (config.map.zoom != null)
"zoom=${toString config.map.zoom}")
];
mkIf 只在条件成立时贡献定义。zoom 为 null 就不发送该参数,让 Google API 推断视野。随后课程把默认值改为 10,表示应用希望默认固定缩放,用户仍可以显式设 null 来恢复自动行为。不要用普通的顶层条件来草率替代依赖最终 config 的条件定义,否则可能引入递归求值问题。
六、包装地理编码,组合位置参数
新增 map.center,类型为 nullOr str,课程默认值为 "switzerland"。它先通过 geocode 脚本把地名转为坐标。为该工具声明一个 package 选项:
options.scripts.geocode = lib.mkOption {
type = lib.types.package;
};
options.map.center = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "switzerland";
};
config.scripts.geocode = pkgs.writeShellApplication {
name = "geocode";
runtimeInputs = with pkgs; [ curl jq ];
text = ''exec ${./geocode.sh} "$@"'';
};
在参数列表中加入:
(lib.mkIf (config.map.center != null)
"center=\"$(${config.scripts.geocode}/bin/geocode ${
lib.escapeShellArg config.map.center
})\"")
escapeShellArg 保护传给 geocode 的地名参数,使空格、引号等保持为一个参数。命令替换的坐标结果再放进带双引号的 center 参数。它保护的是这一处字符串进入 shell 的位置,不代表整个请求生成流程自动免疫注入或 API 参数语义问题。
七、拆分模块,并用 submodule 定义标记
创建 marker.nix,在主模块顶层写 imports = [ ./marker.nix ];。imports 把模块纳入同一套声明、合并和类型检查流程;它不只是文本复制。
地图上的一个标记是带 location 等属性的结构,适合用子模块表示:
{ lib, config, ... }:
let
markerType = lib.types.submodule {
options.location = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
};
};
in
{
options.map.markers = lib.mkOption {
type = lib.types.listOf markerType;
};
config = {
map.markers = [ { location = "new york"; } ];
requestParams =
let
coordinates = builtins.map
(marker: "$(${config.scripts.geocode}/bin/geocode ${
lib.escapeShellArg marker.location})")
config.map.markers;
in [ "markers=\"${lib.concatStringsSep "|" coordinates}\"" ];
};
}
列表中的每个元素都是 markerType 子模块,逐个检查声明。这里显式使用 builtins.map,避免与 map 配置选项混淆。新文件定义的 requestParams 会与主模块的列表合并,让主包装器收到所有参数。
已有至少一个标记时,把 center 条件定义为 null;至少两个标记时,把 zoom 条件定义为 null。这样就让 API 根据标记位置选择中心和缩放:
map.center = lib.mkIf (lib.length config.map.markers >= 1) null;
map.zoom = lib.mkIf (lib.length config.map.markers >= 2) null;
这两项是定义,不是不可覆盖的运行时开关。若同一优先级的其他模块还定义了不同值,可能产生冲突;需要明确配置所有权与优先级。
八、为多名用户组合嵌套子模块
新增 userType 子模块,其中 departure 的类型是 markerType,默认 {} 让 marker 自己的默认选项生效。users 则用 attrsOf userType 表示按名字索引的用户集合:
userType = lib.types.submodule {
options.departure = lib.mkOption {
type = markerType;
default = {};
};
};
# 放入顶层 options:
users = lib.mkOption {
type = lib.types.attrsOf userType;
};
# 用下面的定义替换硬编码的纽约标记:
map.markers = lib.filter
(marker: marker.location != null)
(lib.concatMap (user: [ user.departure ])
(lib.attrValues config.users));
attrValues 取出用户属性集的值,顺序按属性名排序;concatMap 收集每个用户的出发地标记,再过滤 location 为 null 的标记。用户于是可以设置 users.alice.departure.location 等选项,不必直接编辑最终 API 请求。2021 Summer of Nix 的多人互动地图演示便建立在这种组合方式上。
九、用类型约束标签、颜色和尺寸
为 markerType 新增可空标签,要求单个大写字母或数字:
style.label = lib.mkOption {
type = lib.types.nullOr (lib.types.strMatching "[A-Z0-9]");
default = null;
};
生成参数时,每个 marker 单独产生一个 markers=... 参数。若标签非 null,用 lib.optional 加入 label:...,再加入地理编码结果,通过竖线连接。这与早期把全部坐标放在一条 markers 参数中不同,是为了每个标记都能有自己的样式。
用户属性名已经可用,可以从中推导默认标签。原函数把名字转成大写,再查找第一个英文字母或数字;没有匹配时保持 null:
firstUpperAlnum = str:
lib.mapNullable lib.head
(builtins.match "[^A-Z0-9]*([A-Z0-9]).*" (lib.toUpper str));
userType = lib.types.submodule ({ name, ... }: {
options.departure = lib.mkOption {
type = markerType;
default = {};
};
config.departure.style.label =
lib.mkDefault (firstUpperAlnum name);
});
子模块可以是函数。在 attrsOf 中,特殊参数 name 对应所属属性名。mkDefault 给自动推导值较低优先级,用户的普通定义可以覆盖它。优先级数值越小越优先;课程给出的 mkDefault 数值是 1000。原文把它笼统叫“最低优先级”,这里修正为低于普通定义,避免误解为任何机制都无法比它更低。
颜色使用 either 联合类型:既可以是列出的颜色名称,也可以是 0xRRGGBB:
colorType = lib.types.either
(lib.types.strMatching "0x[0-9A-F]{6}")
(lib.types.enum [
"black" "brown" "green" "purple" "yellow"
"blue" "gray" "orange" "red" "white"
]);
# markerType 的 options:
style.color = lib.mkOption {
type = colorType;
default = "red";
};
style.size = lib.mkOption {
type = lib.types.enum [ "tiny" "small" "medium" "large" ];
default = "medium";
};
enum 只允许列出的值。这个正则要求十六进制字母为大写;不要把更宽松输入自动视为已支持。尺寸需要做一次语义映射:tiny→tiny,small→small,medium→mid,large→null。large 用省略 API 参数来表达,而不是发送字符串 null。
paramForMarker = marker:
let
size = {
tiny = "tiny";
small = "small";
medium = "mid";
large = null;
}.${marker.style.size};
attributes =
lib.optional (marker.style.label != null)
"label:${marker.style.label}"
++ lib.optional (size != null) "size:${size}"
++ [
"color:${marker.style.color}"
"$(${config.scripts.geocode}/bin/geocode ${
lib.escapeShellArg marker.location})"
];
in "markers=\"${lib.concatStringsSep "|" attributes}\"";
# marker.nix 的 config.requestParams:
requestParams = builtins.map paramForMarker config.map.markers;
这段是前面参数生成逻辑的替换版,不要与早期同名定义原样叠加。类型约束为标签、颜色和尺寸收窄了取值范围,但地图 API 自身的权限、计费与响应错误仍须另外处理。
十、加入起终点路径
创建 path.nix,声明 map.paths 是 pathType 列表,pathType 的 locations 为字符串列表;每个路径产生一个 path=... 参数:
{ lib, config, ... }:
let
pathType = lib.types.submodule {
options.locations = lib.mkOption {
type = lib.types.listOf lib.types.str;
};
};
attrForLocation = loc:
"$(${config.scripts.geocode}/bin/geocode ${lib.escapeShellArg loc})";
in
{
options.map.paths = lib.mkOption {
type = lib.types.listOf pathType;
};
config.requestParams = builtins.map
(path: ''path="${lib.concatStringsSep "|" (
builtins.map attrForLocation path.locations)}"'')
config.map.paths;
}
在 marker.nix 导入 path.nix;给 userType 增加 arrival,它的声明和 departure 一样,类型 markerType、默认空属性集,并为 arrival 的 label 也设置由用户名称推导的 mkDefault。收集标记时改为同时收集 [ user.departure user.arrival ]。
随后在 path.nix 中定义每个用户的路径,仅保留起点和终点都非 null 的用户:
map.paths = builtins.map
(user: {
locations = [
user.departure.location
user.arrival.location
];
})
(lib.filter
(user:
user.departure.location != null
&& user.arrival.location != null)
(lib.attrValues config.users));
这里展示的是地图上连接位置的 path,并没有调用道路导航服务或返回推荐行车路线。原文以“路线”引入这一章节,但实际代码只是地理编码坐标和 Maps Static 的 path 参数;应按代码的真实能力理解。
十一、把路径样式继续组合到用户接口
新增 pathStyleType 子模块。线宽 weight 采用 ints.between 1 20,上下界都包含,默认 5。线宽越大,路径越粗。pathType 增加 style,类型为 pathStyleType,默认空属性集:
pathStyleType = lib.types.submodule {
options.weight = lib.mkOption {
type = lib.types.ints.between 1 20;
default = 5;
};
};
# pathType 的 options:
style = lib.mkOption {
type = pathStyleType;
default = {};
};
只在路径内部定义样式还不够,用户需要能配置它。path.nix 可以再次声明兼容的 users 子模块,为每个用户扩展 pathStyle;模块系统将它与 marker.nix 声明的 departure、arrival 组合:
options.users = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options.pathStyle = lib.mkOption {
type = pathStyleType;
default = {};
};
});
};
在每个自动生成路径的属性集中再加入 style = user.pathStyle;。这展示了模块系统的关键能力:多个模块可以共同扩展同一个结构化接口,前提是声明类型和合并方式兼容。
路径颜色类似标记颜色,但可以包含 Alpha 通道;正则改为 0x[0-9A-F]{6}([0-9A-F]{2})?,允许 RGB 或 RGBA,默认 blue。最后加入布尔 geodesic,默认 false,为 true 时按球面上的测地线绘制:
# 在 path.nix 的 let 中定义,与 marker.nix 的 colorType 独立:
colorType = lib.types.either
(lib.types.strMatching "0x[0-9A-F]{6}([0-9A-F]{2})?")
(lib.types.enum [
"black" "brown" "green" "purple" "yellow"
"blue" "gray" "orange" "red" "white"
]);
# pathStyleType 的 options 再加入:
color = lib.mkOption {
type = colorType;
default = "blue";
};
geodesic = lib.mkOption {
type = lib.types.bool;
default = false;
};
最终路径参数的属性列表成为:
attributes = [
"weight:${toString path.style.weight}"
"color:${path.style.color}"
"geodesic:${lib.boolToString path.style.geodesic}"
] ++ builtins.map attrForLocation path.locations;
# 仍在 paramForPath 函数内部:
# in "path=\"${lib.concatStringsSep "|" attributes}\"";
必须使用 boolToString 生成 API 期望的布尔文本。这里统一显式写 builtins.map,以避免把示例末尾简写的 map 与配置属性名混为一谈。用户接口最终可表达为:
# 编辑补充:模块定义示例,放入参与 evalModules 的配置模块。
{
users.alice = {
departure.location = "Zurich";
arrival.location = "Bern";
departure.style.color = "red";
arrival.style.color = "green";
pathStyle = {
weight = 4;
color = "0x0000FF80";
geodesic = true;
};
};
}
这段补充用于把前面的选项结构连起来;不是原文的测试结果。课程中的所有逐步补丁都应在对应位置替换或补充,不能把同名示例各自粘贴一次期待它们自动变成完整程序。
十二、辅助脚本的实际安全边界
本稿同时读取了官方教程链接的 map.sh 与 geocode.sh。它们在运行时从 $XDG_DATA_HOME/google-api/key(或脚本中的回退位置)读取密钥,并通过 curl 请求 Google 服务;没有内嵌真实密钥。路径形式的密钥不会自动进入 Nix store,但将密钥本身放进 Nix 配置、在字符串中插值或把含密钥目录当派生输入,都会改变这一点。
对官方公开脚本作静态核对后发现,一处实际缺陷位于缺密钥处理:对密钥文件路径使用 basename 后传给 mkdir,得到的是文件名而不是父目录。这不能可靠建立期望的 google-api 目录。编辑建议改用明确的目录变量,不输出密钥:
# 编辑补充:只说明目录准备,不读取或打印任何 API 密钥。
api_dir="${XDG_DATA_HOME:-$HOME/.local/share}/google-api"
(umask 077; mkdir -p -- "$api_dir")
# 使用你自己的安全凭证流程写入 "$api_dir/key";
# 对新建密钥文件限制访问权限,勿提交到版本库或 Nix store。
这段与原文的差异是正确准备父目录,并为新建目录设置受限 umask;它不会替已有宽权限目录自动修正所有权限,也不会创建密钥内容。本轮没有执行它。对现有路径还应核对所有权和是否为符号链接。
此外,原脚本以 curl 传递密钥参数,密钥可能出现在进程参数或请求日志中;应按使用环境考虑受限权限和避免记录请求 URL。不能把“没有硬编码”解释为“不会泄漏”。请求参数需 URL 编码,网络错误、API 返回状态、空结果与坐标类型也需要明确检查;单凭成功启动 curl 不代表取得有效地图。
安全改写时应把“地名作为参数传给 geocode”“geocode 输出作为数据接收”“地图请求参数编码”分开处理,用数组保存运行时参数并检查结果。不要对不可信配置生成的 shell 文本使用 eval。本文仅指出已见到的问题和必要改动方向,没有伪造一份经端到端测试的生产实现。
学到的是如何让接口逐步组合
入门篇说明 options 负责声明、config 负责定义、evalModules 负责合并与检查。深入篇把同一机制应用到外部 API:从输出脚本开始,提取请求参数,加入可空值与默认值,拆成多个模块,再通过 listOf、attrsOf 和 submodule 表达标记、用户与路径。strMatching、either、enum、整数区间和 bool 把允许的输入写进接口;mkIf 和 mkDefault 则表达条件与覆盖关系。
这些抽象使调用者能用结构化配置表达需求,但它们不会替代外部服务的权限和错误处理,也不会自动让 shell 拼接安全。对本系列的恰当理解,是将可组合的声明式接口与真实运行边界同时设计。
来源:A basic module、Module system deep dive。两篇作者与编辑署名见本文开头,版权 © 2016–2026 NixOS Foundation 与贡献者。依据 nix.dev LICENSE.md,本中文翻译整理沿用 CC BY-SA 4.0;完整许可文本随稿附上,按原样提供且不作担保。改动为系列合并、重复补丁的结构化整理、版本和静态审查说明、明确标记的代码修正,以及原创技术图。
许可与署名文件:作者与来源署名文件;CC BY-SA 4.0 完整许可文本。











暂无评论内容