背景:为什么用Zig重写?之前一直用Go写的zvm,功能没问题,但有几个膈应人的地方:二进制体积:Go编译出来10MB+,静态链接还要带runtime。Zig最终1-2MB,真·零依赖启动延迟:Go程序启动时runtime初始化、GC预热,虽然感知不强,但用多了能
之前一直用 Go 写的 zvm,功能没问题,但有几个膈应人的地方:
说白了就是想看看:Zig 吹的那些零成本抽象、编译期计算、C 级别的控制,在真实项目里到底能不能打。
Go 版本用的是传统的 ~/.zvm 单目录,所有东西塞一块。这次重构决定遵循 XDG Base Directory 规范:
~/.config/zvm/settings.json # 配置
~/.local/share/zvm/0.16.0/zig # 安装的版本
~/.local/share/zvm/bin -> 0.16.0/ # 软链接指向当前版本
~/.cache/zvm/versions.json # 版本列表缓存
这样做的好处是配置、数据、缓存分离,备份/清理/迁移都清楚。坏处是初始化时要分别解析三个环境变量(XDG_CONFIG_HOME、XDG_DATA_HOME、XDG_CACHE_HOME),还要处理 Windows 上没有这些变量时的 fallback。
一个小细节:Windows 上 fallback 到 USERPROFILE/.config,而不是 Roaming/AppData。因为 Zig 工具链偏向开发者,默认藏深目录里反而麻烦。
Unix 上 ln -s 很简单,Windows 上麻烦大了。Go 的 os.Symlink 在 Windows 上需要管理员权限,因为默认创建的是真正的符号链接。
Windows 其实有个叫 junction 的东西,行为类似目录软链接,但不需要管理员权限。这次用 Zig 重构时,直接在 Windows 分支上调用 cmd /c mklink /J,避开权限问题。
另一个坑是 Windows 上删除 junction 要用 rmdir 而不是 del,删除符号链接才是 del。代码里封装了个 removeSymlink 函数,Windows 走 deleteDir,Unix 走 deleteFile。
Go 版本用的 cobra/viper,功能全但笨重。Zig 生态里可选的 CLI 库不多,而且 0.16 版本 API 变动大,依赖第三方风险高。干脆手写。
手写解析器的关键是命令查找要快。用 std.StaticStringMap 在编译期生成命令到枚举的映射表,运行时 O(1) 查找。支持别名(install/i,list/ls)就是在 map 里多加几条 entry。
解析流程分三层:
三层用同一个 args 迭代器顺序消费,代码 300 行左右,没外部依赖,编译飞快。
ziglang.org 官方下载在国内时快时慢,社区维护了一份镜像列表。安装时要选最快的源,逻辑是:
测 RTT 时用 HEAD 而不是 GET,省流量。Zig 的标准库 HTTP 客户端支持自定义方法,几行代码就能实现。
标准库的 HTTP 客户端有个坑:不支持 HTTPS over HTTP 代理(缺少 CONNECT 隧道实现)。企业环境或受限网络中常用 http_proxy 访问外网,这个不支持就废了一半。
解决办法:检测到有代理配置时,fork 到 curl。子进程执行 curl -x proxy -o dest url,stdout/stderr 用 pipe 捕获。虽然多了外部依赖,但 curl 的代理支持是经过实战检验的,SOCKS5、HTTP 代理、认证都支持。
Zig 的 std.process.run API 设计得很舒服,指定 argv 数组、环境变量继承、输出限制(防止内存爆炸),几行代码就能封装好。
Zig 0.16 的标准库有 zip 解压,但 tar.xz 依赖外部的 xz 库,编译时经常出问题。 pragmatic 的选择:
这样依赖最小化,同时避免引入 C 库编译的麻烦。
Zig 官方发布的 tar 包解压后是 zig-macos-x86_64-0.16.0/ 这种长名字,但用户只想看 0.16.0/。解压后要重命名。
坑在于:如果用户指定了 --force 强制重装,老目录要先删掉。但 deleteTree 是危险操作,万一路径拼接错了把家目录删了就完了。这里加了双重校验:
\\d+\\.\\d+\\.\\d+)苹果在 macOS 26 更新了 ld64,导致某些 Zig 版本链接时报 "undefined symbol" 错误。这不是 zvm 的 bug,但用户会以为是安装器坏了。
解决:装完后跑一遍 smoke test。创建一个临时 .zig 文件,执行 zig build-exe -fno-emit-bin,检查 stderr 有没有 "undefined symbol"。有的话就警告用户,建议换 nightly 版本或 Mach 引擎的 build。
这个验证流程本来是可选的,但因为 macOS 用户越来越多,变成默认开启。失败不阻断流程,只打警告。
Go 版本用的 viper,配置改完调用 WriteConfig 才落盘。Zig 版本改为每次修改立即写入。
原因是:CLI 工具生命周期短,用户 Ctrl-C 之后如果配置没写,下次启动状态不对。比如设置了代理没写成功,下次下载还是直连,用户会困惑。
代价是写文件次数变多。但配置 JSON 就几百字节,现代 SSD 无感知。
Zig 没有 GC,配置里那些 URL 字符串(version_map_url、proxy 等)要手动管理。Settings 结构体初始化时把所有字符串 dup 到堆上,deinit 时逐个 free。
有个技巧:optional 字段(比如 path)要先判断再决定是否 free,避免 double free。代码里每个 allocator.dupe 对应一个 allocator.free,成对出现。
Go 的错误处理是 if err != nil,Zig 是 try/catch 风格的 error union。重构时最大的思维转换是:不要把所有错误都往上抛。
比如读取设置文件时,文件不存在不应该 fatal,应该用默认值创建新配置。这种时候用 catch 捕获特定 error,而不是 try 抛给上层。
const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
error.FileNotFound => return createDefaultSettings(),
else => return err, // 其他错误再抛
};
Go 版本测速镜像时直接 go func() { measure() }(),wait group 一收就完。Zig 没有 goroutine,但有 async/await(虽然 0.16 还在实验阶段)。
实际用的是阻塞式顺序执行 + 超时控制。因为镜像测速就是发个 HEAD 请求,串行执行对总耗时影响有限,而且代码简单很多。如果以后镜像列表变长,可以改成用 std.Thread 开几个线程做并行。
Go 用 interface 做命令抽象,Zig 用 tagged union + switch 实现类似效果:
const Command = union(enum) {
install: InstallArgs,
use: UseArgs,
list: ListArgs,
// ...
};
switch (cmd) {
.install => |args| try install.run(args),
.use => |args| try use.run(args),
// ...
}
这种写法没有虚函数开销,switch 编译期可以优化成跳转表。代价是加新命令要改 switch,不像 Go 那样可以注册。
最终 ReleaseSafe 构建 1.5MB 左右,对比 Go 版本的 10MB+,主要来源:
zig build -Doptimize=ReleaseSafe -Dstrip=true 可以更小Debug 构建会大很多(10MB+),主要是调试符号和未优化的代码。发布时用 ReleaseSafe,保留安全检查但优化体积。
GitHub Actions 构建 6 个目标:x86_64-linux、aarch64-linux、x86_64-macos、aarch64-macos、x86_64-windows、aarch64-windows。
Zig 的交叉编译是原生支持的,不需要 Docker 或 qemu。build.zig 里指定 target,一台 Linux 机器就能编出所有二进制。
打 tag v0.2.0 触发 CI,build.zig 里用 git describe --tags 获取版本号,通过 @import("build_options") 注入到代码里。本地构建没有 tag 时 fallback 到 "0.0.1",确保 CI 版本永远高于本地,方便 zvm upgrade 检测更新。
install.sh 做几件事:
关键点是幂等:重复执行不会重复加 PATH。用 grep 检查配置里有没有 zvm 字样,没有再追加。
Zig 版本锁定
0.16 到 0.15 API 变动巨大,特别是 std.Io 从 std.io 改名,所有文件操作 API 签名变了。项目根目录放个 .zig-version 文件,CI 里用指定版本构建,避免上游更新导致失败。
JSON 解析的内存管理 std.json 解析出来的 Value 树要手动 deinit,字符串都是指向原始 buffer 的切片。如果原始 buffer 是栈上的,函数返回后 value 就悬空了。所有 JSON 操作在同一个函数内完成,或者把原始 buffer 也堆分配。
Windows 路径长度
Windows 传统路径限制 260 字符,junction/symlink 在超长路径下行为诡异。用 \\?\\ 前缀开启 extended path 模式可以突破限制,但大部分时候没必要,Zig 安装路径通常不会那么深。
macOS Gatekeeper 下载的二进制没有签名,第一次运行会被 Gatekeeper 拦。这不是 zvm 能解决的,要在文档里提醒用户去系统设置点"允许"。或者引导用户用 Homebrew 安装(社区维护的 tap)。
代理环境的 PATH 某些代理软件会劫持 PATH,zvm 加的 PATH 可能被顶到后面。在文档里强调 "把 zvm 的 PATH 放最前面",或者检测 $PATH 里有没有 zvm,有但不在第一位时警告。
适合,但有前提:
适合的情况:
不适合的情况:
zvm 这种规模(3k+ 行代码)的工具,Zig 的表现超出预期。编译后的二进制是真正的"单文件可执行",scp 到任意同架构机器就能跑,没有"在目标机器装依赖"的烦恼。
项目开源在
欢迎试玩或提 issue。如果你也在用 Zig 写工具,上面这些坑应该能帮你省点时间。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!