Skip to content

Commit 240da42

Browse files
authored
feat(content): Add post about rewriting zvm from Go to Zig (#115)
* feat(content): Add post about rewriting zvm from Go to Zig Add a new blog post sharing practical experience of rewriting the zvm version manager from Go to Zig. The post covers: - Core architecture and directory structure design (XDG Base Directory) - Windows junction vs symlink handling - Hand-written CLI parser using std.StaticStringMap - Network layer trade-offs (mirror speed test, proxy fallback) - Installation details (tar.xz/zip extraction, macOS 26 smoke test) - Configuration persistence and string lifetime management - Error handling and tagged union patterns in Zig - Binary size optimization and CI release matrix * style(content): Fix spacing issues in zvm post Apply autocorrect fixes to the newly added zvm post for consistent formatting around operators and punctuation. Changes: - Add spaces around the plus operator in mirror speed test description - Remove extra space before "pragmatic" after a Chinese period * fix(content): Correct macOS version and project link in zvm post Update the zvm blog post for factual accuracy and cleaner formatting. Changes: - Remove incorrect "Sequoia" codename from macOS 26 reference, since Sequoia is actually macOS 15 - Convert the project repository link from a blockquote to an inline Markdown link for better readability
1 parent 733cd0e commit 240da42

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

content/post/2026-04-15-zvm.smd

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)