Go 程序自更新:下载、校验与安全替换
cbowen

有些命令行工具或桌面小工具发布以后,希望用户不用重新下载压缩包,也能在程序内部完成更新。Go 程序做自更新时,常见流程是:

  1. 请求一个 latest.json,获取最新版本和各平台下载地址。
  2. 根据当前系统和 CPU 架构选择对应安装包。
  3. 下载新的二进制文件,同时显示下载进度。
  4. 计算下载文件的 SHA256,确认文件没有损坏或被替换。
  5. 用新文件替换当前正在运行的程序。

下面用 github.com/inconshreveable/go-update 做一个比较完整的示例。

安装依赖

先创建一个普通 Go 项目:

1
2
3
mkdir self-update-demo
cd self-update-demo
go mod init self-update-demo

安装自更新依赖:

1
go get github.com/inconshreveable/go-update

这个库负责把下载好的新程序替换到当前可执行文件的位置,并在失败时尽量回滚。

latest.json 格式

服务端可以放一个 latest.json,里面保存当前最新版本和不同平台的下载地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"version": "1.0.1",
"platforms": {
"darwin-amd64": {
"url": "https://example.com/downloads/myapp-darwin-amd64",
"sha256": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
},
"darwin-arm64": {
"url": "https://example.com/downloads/myapp-darwin-arm64",
"sha256": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04d4af34ed6bba65d69"
},
"linux-amd64": {
"url": "https://example.com/downloads/myapp-linux-amd64",
"sha256": "baa5a0964d3320fbc0c6a922140453c8513ea24ab8fd0577034804a967248096"
},
"windows-amd64": {
"url": "https://example.com/downloads/myapp-windows-amd64.exe",
"sha256": "21f58d27f827d295ffcd860c65045685e3baf1ad4506caa0140113b316647534"
}
}
}

平台名使用 runtime.GOOS + "-" + runtime.GOARCH 拼出来。常见值有:

  • darwin-amd64
  • darwin-arm64
  • linux-amd64
  • windows-amd64

完整代码

创建 main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"time"

update "github.com/inconshreveable/go-update"
)

const latestURL = "https://example.com/latest.json"

type ProgressReader struct {
Reader io.Reader
Total int64
Downloaded int64
OnProgress func(percent float64, downloaded int64, total int64)
}

func (p *ProgressReader) Read(b []byte) (int, error) {
n, err := p.Reader.Read(b)
if n > 0 {
p.Downloaded += int64(n)
if p.Total > 0 && p.OnProgress != nil {
percent := float64(p.Downloaded) / float64(p.Total) * 100
p.OnProgress(percent, p.Downloaded, p.Total)
}
}
return n, err
}

type UpdateInfo struct {
Version string `json:"version"`
Platforms map[string]PlatformInfo `json:"platforms"`
}

type PlatformInfo struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
}

func currentPlatform() string {
return runtime.GOOS + "-" + runtime.GOARCH
}

func fetchLatestInfo(client *http.Client, latestURL string) (PlatformInfo, error) {
resp, err := client.Get(latestURL)
if err != nil {
return PlatformInfo{}, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return PlatformInfo{}, fmt.Errorf("获取 latest.json 失败,状态码: %d", resp.StatusCode)
}

var info UpdateInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return PlatformInfo{}, fmt.Errorf("解析 latest.json 失败: %w", err)
}

platform := currentPlatform()
platformInfo, ok := info.Platforms[platform]
if !ok {
return PlatformInfo{}, fmt.Errorf("latest.json 中没有当前平台 %s 的更新包", platform)
}

platformInfo.URL = strings.TrimSpace(platformInfo.URL)
platformInfo.SHA256 = strings.TrimSpace(platformInfo.SHA256)

if platformInfo.URL == "" {
return PlatformInfo{}, fmt.Errorf("latest.json 中 %s 缺少 url", platform)
}
if platformInfo.SHA256 == "" {
return PlatformInfo{}, fmt.Errorf("latest.json 中 %s 缺少 sha256", platform)
}

return platformInfo, nil
}

func downloadAndVerify(client *http.Client, downloadURL, expectedSHA256 string) (*os.File, error) {
resp, err := client.Get(downloadURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载失败,状态码: %d", resp.StatusCode)
}

tmpFile, err := os.CreateTemp("", "self-update-*")
if err != nil {
return nil, err
}

cleanup := func() {
tmpFile.Close()
os.Remove(tmpFile.Name())
}

hasher := sha256.New()
reader := &ProgressReader{
Reader: resp.Body,
Total: resp.ContentLength,
OnProgress: func(percent float64, downloaded int64, total int64) {
fmt.Printf("\r下载进度: %.2f%% %d/%d bytes", percent, downloaded, total)
},
}

if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), reader); err != nil {
cleanup()
return nil, err
}
fmt.Println()

actualSHA256 := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(actualSHA256, expectedSHA256) {
cleanup()
return nil, fmt.Errorf("sha256 校验失败: expected %s, got %s", expectedSHA256, actualSHA256)
}
fmt.Println("sha256 校验通过")

if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
cleanup()
return nil, err
}

return tmpFile, nil
}

func ApplyUpdate(latestURL string) error {
client := &http.Client{Timeout: 10 * time.Minute}

info, err := fetchLatestInfo(client, latestURL)
if err != nil {
return err
}

updateFile, err := downloadAndVerify(client, info.URL, info.SHA256)
if err != nil {
return err
}
defer func() {
updateFile.Close()
os.Remove(updateFile.Name())
}()

err = update.Apply(updateFile, update.Options{})
if err != nil {
if rerr := update.RollbackError(err); rerr != nil {
return rerr
}
return err
}

fmt.Println("更新成功")
return nil
}

func main() {
if err := ApplyUpdate(latestURL); err != nil {
panic(err)
}
}

latestURL 改成自己的更新地址后,就可以在程序里调用 ApplyUpdate

代码说明

ProgressReader 包装了原始的 io.Reader,每次读取时累加已经下载的字节数。如果响应里有 Content-Length,就可以计算百分比:

1
percent := float64(p.Downloaded) / float64(p.Total) * 100

这里没有把进度输出写死在 Read 里面,而是通过 OnProgress 回调传出去。这样以后不管是命令行进度条、GUI 进度条,还是日志输出,都可以复用同一个下载逻辑。

fetchLatestInfo 做三件事:

  • 下载 latest.json
  • 解析 JSON
  • 根据当前平台取出对应的下载地址和 SHA256

当前平台通过下面的函数得到:

1
2
3
func currentPlatform() string {
return runtime.GOOS + "-" + runtime.GOARCH
}

如果当前是 macOS Apple Silicon,结果就是 darwin-arm64;如果是 64 位 Linux,通常是 linux-amd64

downloadAndVerify 会把下载内容同时写入临时文件和 SHA256 计算器:

1
io.Copy(io.MultiWriter(tmpFile, hasher), reader)

这样不用先完整下载文件再读一遍计算哈希。下载完成后,如果实际 SHA256 和 latest.json 里的值不一致,就删除临时文件并返回错误。

最后 ApplyUpdate 会调用:

1
update.Apply(updateFile, update.Options{})

这个操作会尝试用新文件替换当前程序。如果替换过程中失败,可以通过 update.RollbackError(err) 获取回滚阶段的错误。

生成 SHA256

发布新版本时,需要先编译不同平台的程序,再计算每个文件的 SHA256。

Linux 和 macOS 可以使用:

1
shasum -a 256 myapp-linux-amd64

Windows 可以在 PowerShell 中使用:

1
Get-FileHash .\myapp-windows-amd64.exe -Algorithm SHA256

把得到的哈希值填到 latest.json 里即可。

打包不同平台

Go 可以交叉编译不同平台的二进制:

1
2
3
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe

如果项目依赖 CGO,交叉编译会复杂一些,需要准备对应平台的 C 编译环境。纯 Go 项目通常会简单很多。

常见注意事项

自更新最好使用 HTTPS 地址,避免下载内容在传输过程中被篡改。SHA256 可以发现文件不一致,但不能证明 latest.json 本身可信。如果安全要求更高,可以再加签名校验。

程序需要有权限替换自己。如果可执行文件放在系统目录,例如 /usr/local/binC:\Program Files,普通用户可能没有写权限,需要管理员权限或安装器配合。

Windows 对正在运行的可执行文件锁定更严格,自替换比 Linux 和 macOS 更容易遇到限制。实际发布前一定要在目标平台上测试一次。

更新完成后,当前进程里运行的仍然是旧代码。通常做法是提示用户重启程序,或者由外部启动器负责重新拉起新版本。

latest.json 里的 version 字段在上面的示例中没有参与判断。如果不希望每次都下载,可以在本地保存当前版本号,先比较版本,再决定是否调用下载逻辑。

小结

这个方案的核心是把更新拆成几个清楚的步骤:获取元信息、选择平台、下载文件、校验哈希、替换程序。只要更新源可靠、哈希值正确,并且目标路径有写入权限,就可以给 Go 命令行工具或桌面小工具加上一个简单可控的自更新能力。

 评论
评论插件加载失败
正在加载评论插件