|
| 1 | +--- |
| 2 | +.title = "用 Zig 重写一个版本管理工具:从 Go 到 Zig 的实战经验", |
| 3 | +.date = @date("2026-04-15T16:11:11+0800"), |
| 4 | +.author = "lispking", |
| 5 | +.layout = "post.shtml", |
| 6 | +.draft = false, |
| 7 | +--- |
| 8 | + |
| 9 | + |
| 10 | +## 背景:为什么用 Zig 重写? |
| 11 | + |
| 12 | +之前一直用 Go 写的 zvm,功能没问题,但有几个膈应人的地方: |
| 13 | + |
| 14 | +1. **二进制体积**:Go 编译出来 10MB+,静态链接还要带 runtime。Zig 最终 1-2MB,真·零依赖 |
| 15 | +2. **启动延迟**:Go 程序启动时 runtime 初始化、GC 预热,虽然感知不强,但用多了能感觉到那种"黏滞感" |
| 16 | +3. **平台适配**:Go 的跨平台是强,但 syscall 封装层太厚,想改点底层行为(比如 Windows 用 junction 而不是 symlink)得绕好几层 |
| 17 | +4. **学习成本**:既然在写 Zig 项目,不如直接用 Zig 写工具,强迫自己深入语言 |
| 18 | + |
| 19 | +说白了就是想看看:Zig 吹的那些零成本抽象、编译期计算、C 级别的控制,在真实项目里到底能不能打。 |
| 20 | + |
| 21 | +## 核心架构对比 |
| 22 | + |
| 23 | +### 目录结构的设计哲学 |
| 24 | + |
| 25 | +Go 版本用的是传统的 `~/.zvm` 单目录,所有东西塞一块。这次重构决定遵循 XDG Base Directory 规范: |
| 26 | + |
| 27 | +``` |
| 28 | +~/.config/zvm/settings.json # 配置 |
| 29 | +~/.local/share/zvm/0.16.0/zig # 安装的版本 |
| 30 | +~/.local/share/zvm/bin -> 0.16.0/ # 软链接指向当前版本 |
| 31 | +~/.cache/zvm/versions.json # 版本列表缓存 |
| 32 | +``` |
| 33 | + |
| 34 | +这样做的好处是配置、数据、缓存分离,备份/清理/迁移都清楚。坏处是初始化时要分别解析三个环境变量(XDG_CONFIG_HOME、XDG_DATA_HOME、XDG_CACHE_HOME),还要处理 Windows 上没有这些变量时的 fallback。 |
| 35 | + |
| 36 | +一个小细节:Windows 上 fallback 到 USERPROFILE/.config,而不是 Roaming/AppData。因为 Zig 工具链偏向开发者,默认藏深目录里反而麻烦。 |
| 37 | + |
| 38 | +### 版本切换:软链接的坑 |
| 39 | + |
| 40 | +Unix 上 `ln -s` 很简单,Windows 上麻烦大了。Go 的 os.Symlink 在 Windows 上需要管理员权限,因为默认创建的是真正的符号链接。 |
| 41 | + |
| 42 | +Windows 其实有个叫 junction 的东西,行为类似目录软链接,但不需要管理员权限。这次用 Zig 重构时,直接在 Windows 分支上调用 `cmd /c mklink /J`,避开权限问题。 |
| 43 | + |
| 44 | +另一个坑是 Windows 上删除 junction 要用 `rmdir` 而不是 `del`,删除符号链接才是 `del`。代码里封装了个 `removeSymlink` 函数,Windows 走 deleteDir,Unix 走 deleteFile。 |
| 45 | + |
| 46 | +### CLI 解析:为什么不直接用库 |
| 47 | + |
| 48 | +Go 版本用的 cobra/viper,功能全但笨重。Zig 生态里可选的 CLI 库不多,而且 0.16 版本 API 变动大,依赖第三方风险高。干脆手写。 |
| 49 | + |
| 50 | +手写解析器的关键是命令查找要快。用 `std.StaticStringMap` 在编译期生成命令到枚举的映射表,运行时 O(1) 查找。支持别名(install/i,list/ls)就是在 map 里多加几条 entry。 |
| 51 | + |
| 52 | +解析流程分三层: |
| 53 | +1. 全局 flags(--color、--version) |
| 54 | +2. 命令名匹配(install、use、list...) |
| 55 | +3. 命令专属参数(版本号、--force 等) |
| 56 | + |
| 57 | +三层用同一个 args 迭代器顺序消费,代码 300 行左右,没外部依赖,编译飞快。 |
| 58 | + |
| 59 | +## 网络层的权衡 |
| 60 | + |
| 61 | +### 镜像测速的实现思路 |
| 62 | + |
| 63 | +ziglang.org 官方下载在国内时快时慢,社区维护了一份镜像列表。安装时要选最快的源,逻辑是: |
| 64 | + |
| 65 | +1. 先检查本地有没有缓存的"最快镜像",且 24 小时内测过,直接用 |
| 66 | +2. 否则并发向官方源 + 所有镜像发 HEAD 请求,测 RTT |
| 67 | +3. 按延迟排序,依次尝试下载,第一个成功的缓存为"最快镜像" |
| 68 | +4. 如果全都失败,fallback 到官方源 |
| 69 | + |
| 70 | +测 RTT 时用 HEAD 而不是 GET,省流量。Zig 的标准库 HTTP 客户端支持自定义方法,几行代码就能实现。 |
| 71 | + |
| 72 | +### 代理的 fallback 策略 |
| 73 | + |
| 74 | +标准库的 HTTP 客户端有个坑:不支持 HTTPS over HTTP 代理(缺少 CONNECT 隧道实现)。企业环境或受限网络中常用 http_proxy 访问外网,这个不支持就废了一半。 |
| 75 | + |
| 76 | +解决办法:检测到有代理配置时,fork 到 curl。子进程执行 `curl -x proxy -o dest url`,stdout/stderr 用 pipe 捕获。虽然多了外部依赖,但 curl 的代理支持是经过实战检验的,SOCKS5、HTTP 代理、认证都支持。 |
| 77 | + |
| 78 | +Zig 的 `std.process.run` API 设计得很舒服,指定 argv 数组、环境变量继承、输出限制(防止内存爆炸),几行代码就能封装好。 |
| 79 | + |
| 80 | +## 安装流程的魔鬼细节 |
| 81 | + |
| 82 | +### 解压:tar.xz 和 zip 的双重标准 |
| 83 | + |
| 84 | +Zig 0.16 的标准库有 zip 解压,但 tar.xz 依赖外部的 xz 库,编译时经常出问题。pragmatic 的选择: |
| 85 | + |
| 86 | +- .tar.xz:调用系统 tar 命令(Unix 和 Windows 都有,Git for Windows 自带) |
| 87 | +- .zip:用标准库的 std.zip |
| 88 | + |
| 89 | +这样依赖最小化,同时避免引入 C 库编译的麻烦。 |
| 90 | + |
| 91 | +### 目录重命名的时机 |
| 92 | + |
| 93 | +Zig 官方发布的 tar 包解压后是 `zig-macos-x86_64-0.16.0/` 这种长名字,但用户只想看 `0.16.0/`。解压后要重命名。 |
| 94 | + |
| 95 | +坑在于:如果用户指定了 `--force` 强制重装,老目录要先删掉。但 deleteTree 是危险操作,万一路径拼接错了把家目录删了就完了。这里加了双重校验: |
| 96 | +1. 只删版本号格式的目录(正则匹配 `\\d+\\.\\d+\\.\\d+`) |
| 97 | +2. 操作前打印 "Removing old installation..." |
| 98 | + |
| 99 | +### 安装后验证:macOS 26+ 的坑 |
| 100 | + |
| 101 | +苹果在 macOS 26 更新了 ld64,导致某些 Zig 版本链接时报 "undefined symbol" 错误。这不是 zvm 的 bug,但用户会以为是安装器坏了。 |
| 102 | + |
| 103 | +解决:装完后跑一遍 smoke test。创建一个临时 `.zig` 文件,执行 `zig build-exe -fno-emit-bin`,检查 stderr 有没有 "undefined symbol"。有的话就警告用户,建议换 nightly 版本或 Mach 引擎的 build。 |
| 104 | + |
| 105 | +这个验证流程本来是可选的,但因为 macOS 用户越来越多,变成默认开启。失败不阻断流程,只打警告。 |
| 106 | + |
| 107 | +## 配置持久化的设计 |
| 108 | + |
| 109 | +### 立即写入 vs 延迟写入 |
| 110 | + |
| 111 | +Go 版本用的 viper,配置改完调用 WriteConfig 才落盘。Zig 版本改为**每次修改立即写入**。 |
| 112 | + |
| 113 | +原因是:CLI 工具生命周期短,用户 Ctrl-C 之后如果配置没写,下次启动状态不对。比如设置了代理没写成功,下次下载还是直连,用户会困惑。 |
| 114 | + |
| 115 | +代价是写文件次数变多。但配置 JSON 就几百字节,现代 SSD 无感知。 |
| 116 | + |
| 117 | +### 字符串生命周期的管理 |
| 118 | + |
| 119 | +Zig 没有 GC,配置里那些 URL 字符串(version_map_url、proxy 等)要手动管理。Settings 结构体初始化时把所有字符串 dup 到堆上,deinit 时逐个 free。 |
| 120 | + |
| 121 | +有个技巧:optional 字段(比如 path)要先判断再决定是否 free,避免 double free。代码里每个 allocator.dupe 对应一个 allocator.free,成对出现。 |
| 122 | + |
| 123 | +## 从 Go 带来的思路,在 Zig 里怎么落地 |
| 124 | + |
| 125 | +### 错误处理:从多返回值到 error union |
| 126 | + |
| 127 | +Go 的错误处理是 `if err != nil`,Zig 是 `try/catch` 风格的 error union。重构时最大的思维转换是:**不要把所有错误都往上抛**。 |
| 128 | + |
| 129 | +比如读取设置文件时,文件不存在不应该 fatal,应该用默认值创建新配置。这种时候用 `catch` 捕获特定 error,而不是 `try` 抛给上层。 |
| 130 | + |
| 131 | +```zig |
| 132 | +const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { |
| 133 | + error.FileNotFound => return createDefaultSettings(), |
| 134 | + else => return err, // 其他错误再抛 |
| 135 | +}; |
| 136 | +``` |
| 137 | + |
| 138 | +### 并发:从 goroutine 到 explicit async |
| 139 | + |
| 140 | +Go 版本测速镜像时直接 `go func() { measure() }()`,wait group 一收就完。Zig 没有 goroutine,但有 async/await(虽然 0.16 还在实验阶段)。 |
| 141 | + |
| 142 | +实际用的是**阻塞式顺序执行 + 超时控制**。因为镜像测速就是发个 HEAD 请求,串行执行对总耗时影响有限,而且代码简单很多。如果以后镜像列表变长,可以改成用 std.Thread 开几个线程做并行。 |
| 143 | + |
| 144 | +### 接口:从 interface 到 tagged union |
| 145 | + |
| 146 | +Go 用 interface 做命令抽象,Zig 用 tagged union + switch 实现类似效果: |
| 147 | + |
| 148 | +```zig |
| 149 | +const Command = union(enum) { |
| 150 | + install: InstallArgs, |
| 151 | + use: UseArgs, |
| 152 | + list: ListArgs, |
| 153 | + // ... |
| 154 | +}; |
| 155 | + |
| 156 | +switch (cmd) { |
| 157 | + .install => |args| try install.run(args), |
| 158 | + .use => |args| try use.run(args), |
| 159 | + // ... |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +这种写法没有虚函数开销,switch 编译期可以优化成跳转表。代价是加新命令要改 switch,不像 Go 那样可以注册。 |
| 164 | + |
| 165 | +## 二进制体积优化的技巧 |
| 166 | + |
| 167 | +最终 ReleaseSafe 构建 1.5MB 左右,对比 Go 版本的 10MB+,主要来源: |
| 168 | + |
| 169 | +1. **没有 runtime**:Zig 编译出来就是裸二进制,没 GC、没 runtime 初始化 |
| 170 | +2. **静态链接默认开**:Zig 的 libc 可以选择 musl 静态链接,不用动态链接系统库 |
| 171 | +3. **代码精简**:没引入第三方库,所有功能自己写或用标准库 |
| 172 | +4. **strip 符号**:`zig build -Doptimize=ReleaseSafe -Dstrip=true` 可以更小 |
| 173 | + |
| 174 | +Debug 构建会大很多(10MB+),主要是调试符号和未优化的代码。发布时用 ReleaseSafe,保留安全检查但优化体积。 |
| 175 | + |
| 176 | +## 测试和发布的工程化 |
| 177 | + |
| 178 | +### CI 构建矩阵 |
| 179 | + |
| 180 | +GitHub Actions 构建 6 个目标:x86_64-linux、aarch64-linux、x86_64-macos、aarch64-macos、x86_64-windows、aarch64-windows。 |
| 181 | + |
| 182 | +Zig 的交叉编译是原生支持的,不需要 Docker 或 qemu。build.zig 里指定 target,一台 Linux 机器就能编出所有二进制。 |
| 183 | + |
| 184 | +### 版本号注入 |
| 185 | + |
| 186 | +打 tag `v0.2.0` 触发 CI,build.zig 里用 `git describe --tags` 获取版本号,通过 `@import("build_options")` 注入到代码里。本地构建没有 tag 时 fallback 到 "0.0.1",确保 CI 版本永远高于本地,方便 `zvm upgrade` 检测更新。 |
| 187 | + |
| 188 | +### 安装脚本的设计 |
| 189 | + |
| 190 | +`install.sh` 做几件事: |
| 191 | +1. 检测平台(uname -sm) |
| 192 | +2. 拼接下载 URL(github.com/.../latest/download/) |
| 193 | +3. 下载、解压、移动到 /usr/local/bin 或 ~/.local/bin |
| 194 | +4. 自动检测 shell($SHELL),在 .zshrc/.bashrc 里追加 PATH 设置 |
| 195 | +5. 生成 shell completion 并提示用户 source |
| 196 | + |
| 197 | +关键点是**幂等**:重复执行不会重复加 PATH。用 grep 检查配置里有没有 zvm 字样,没有再追加。 |
| 198 | + |
| 199 | +## 踩坑清单(血泪史) |
| 200 | + |
| 201 | +**Zig 版本锁定** |
| 202 | +0.16 到 0.15 API 变动巨大,特别是 `std.Io` 从 `std.io` 改名,所有文件操作 API 签名变了。项目根目录放个 `.zig-version` 文件,CI 里用指定版本构建,避免上游更新导致失败。 |
| 203 | + |
| 204 | +**JSON 解析的内存管理** |
| 205 | +std.json 解析出来的 Value 树要手动 deinit,字符串都是指向原始 buffer 的切片。如果原始 buffer 是栈上的,函数返回后 value 就悬空了。所有 JSON 操作在同一个函数内完成,或者把原始 buffer 也堆分配。 |
| 206 | + |
| 207 | +**Windows 路径长度** |
| 208 | +Windows 传统路径限制 260 字符,junction/symlink 在超长路径下行为诡异。用 `\\?\\` 前缀开启 extended path 模式可以突破限制,但大部分时候没必要,Zig 安装路径通常不会那么深。 |
| 209 | + |
| 210 | +**macOS Gatekeeper** |
| 211 | +下载的二进制没有签名,第一次运行会被 Gatekeeper 拦。这不是 zvm 能解决的,要在文档里提醒用户去系统设置点"允许"。或者引导用户用 Homebrew 安装(社区维护的 tap)。 |
| 212 | + |
| 213 | +**代理环境的 PATH** |
| 214 | +某些代理软件会劫持 PATH,zvm 加的 PATH 可能被顶到后面。在文档里强调 "把 zvm 的 PATH 放最前面",或者检测 $PATH 里有没有 zvm,有但不在第一位时警告。 |
| 215 | + |
| 216 | +## 总结:Zig 适合写这种工具吗? |
| 217 | + |
| 218 | +适合,但有前提: |
| 219 | + |
| 220 | +**适合的情况**: |
| 221 | +- 对二进制体积敏感(嵌入式、容器、分发) |
| 222 | +- 需要精确控制内存和文件生命周期 |
| 223 | +- 不想带 runtime/VM,追求启动速度 |
| 224 | +- 愿意手写一些 Go/Rust 里用库解决的代码 |
| 225 | + |
| 226 | +**不适合的情况**: |
| 227 | +- 生态不成熟,很多场景要手写或用 C 库(不像 Go/Rust 有丰富的第三方库) |
| 228 | +- 团队对 Zig 不熟悉,维护成本高 |
| 229 | +- 需要大量高级抽象(ORM、完整 HTTP 框架、测试框架) |
| 230 | + |
| 231 | +zvm 这种规模(3k+ 行代码)的工具,Zig 的表现超出预期。编译后的二进制是真正的"单文件可执行",scp 到任意同架构机器就能跑,没有"在目标机器装依赖"的烦恼。 |
| 232 | + |
| 233 | +项目开源在 [lispking/zvm](https://github.com/lispking/zvm) |
| 234 | + |
| 235 | +欢迎试玩或提 issue。如果你也在用 Zig 写工具,上面这些坑应该能帮你省点时间。 |
0 commit comments