1. 为什么 .NET 应用的自动升级容易在三个环节翻车
我去年在给一款面向 Windows、Linux、macOS 三端的 .NET 桌面工具做自动升级。一开始以为很简单,客户端发现新版本,下载文件,替换,重启,完事。真正做起来才发现这套链路里全是雷:Windows 文件被占用、Linux 可执行权限丢失、macOS 下载包被 quarantine、版本号用字符串比较直接翻车。折腾三周之后我把这个组件沉淀成了一个开源项目,今天这篇就把它拆开讲一遍——组件为什么这样设计、核心代码怎么写、跨平台到底有哪些隐藏差异,以及我在生产环境踩过的坑。
1.1 文件占用:三个平台的脾气完全不一样
Windows 上,正在运行的 exe 和 dll 会被系统锁住,直接移动、删除都会抛 UnauthorizedAccessException。所以凡是“程序自己替换自己”的方案,在 Windows 上基本走不通——你不退出进程,就没法动它的主模块文件。
Linux 和 macOS 相对宽松,进程运行用的是 inode,你把磁盘上的文件替换掉,正在跑的旧进程还能继续跑,新进程读到的就是新文件。听起来很方便,但这也带来一个反向陷阱:很多开发者因此在 Linux 上放松了警惕,结果换了一个正在被进程映射的共享库,或者替换 .app 包内文件时遇到缓存和权限问题,反而更隐蔽。另外 macOS 的 .app 是一个完整的 bundle,如果你只替换里面的可执行文件而不更新 Info.plist 等资源,版本号、图标、签名信息会前后不一致,用户看到的还是老版本。
1.2 版本号比较:字符串排序是个隐蔽的坑
“发现新版本”要做的第一件事是版本比较。很多人直接写 if (newVersion > currentVersion),但 version 如果是字符串,这个比较结果往往不对。
举个例子:当前版本 1.9.0,服务端返回 1.10.0。按字符串字典序比较,"1.10.0" < "1.9.0",于是客户端永远认为服务端没有新版本,用户永远等不到更新。这个问题我在自测时抓了好久才意识到,因为版本号恰好撞到了 1.9 到 1.10 这个临界点。
.NET 自带的 Version.Parse("1.10.0") 可以正确处理四段数字版本号,但如果你的版本号带有 -beta.1 这种预发布后缀,Version.Parse 会直接抛异常。我的做法是:主版本号必然用纯数字四段,预发布信息放在 releaseNotes 里;如果你确实需要 SemVer 语义,直接引用 NuGet.Versioning 包,用它的 NuGetVersion 类做比较,省得自己写解析器。
1.3 “升级包损坏一半”的模型:网络不可靠
还有一个容易翻车的认知:很多人假设“服务端文件存在、下载成功 = 文件没问题”。实际上断网、代理中断、服务端 CDN 缓存不一致,都会让你拿到半个包或一个损坏的压缩包。如果组件不校验下载完整性,直接把损坏文件覆盖成正式版本,轻则应用起不来,重则需要用户手动重新安装。
所以我在组件里把“下载-校验-替换”做成了三个阶段,中间用临时文件隔离。任何一步失败,都只清理临时文件,绝不动正式目录。本节后面会详细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组件设计:主程序、下载器和升级进程的三层分工
这个组件最核心的设计判断,是把自动升级拆成三个角色,而不是让主程序一个方法干完所有事。拆分之后,每个角色只负责一小块,故障边界非常清晰。
2.1 服务端:一个静态 JSON 清单就够
服务端我推荐做成“静态文件托管 + JSON 清单”的模式,不引入数据库和后端服务。原因是升级服务的高频操作只有两个:客户端来查有没有新版本,客户端下载更新包。这两种操作,静态服务器和 CDN 就能应付,没必要为它维护一套在线服务。
服务端目录大概是这样的:
code复制releases/
1.8.0/
update.zip
2.3.0/
update.zip
manifest.json
manifest.json 就是升级清单:
json复制{
"manifestVersion": 1,
"appId": "com.example.myapp",
"channel": "stable",
"version": "2.3.0",
"minSupportedVersion": "1.8.0",
"releaseNotes": "修复了 XXX",
"packageUrl": "https://update.example.com/releases/2.3.0/update.zip",
"packageSize": 4521984,
"packageSha256": "E5F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0",
"files": [
{ "path": "MyApp.dll", "size": 1847296, "sha256": "A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1" },
{ "path": "myapp", "size": 809620, "sha256": "D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1B2C3", "executable": true }
]
}
packageUrl 指向整个更新包压缩包,files 数组记录了解压后的每个文件及其哈希。这样设计的好处是:客户端先下载压缩包,再用清单里记录的文件哈希逐个校验,任何一个文件不符都能定位,而不会出现“包能解压但某个 dll 是坏的”这种模糊状态。
如果服务端走 HTTPS,传输层已经能保证基本安全。更进一步,组件里还可以给 manifest 加 RSA 签名,客户端内置公钥验签,防止有人直接篡改更新源。签名逻辑不复杂,但属于“可以后加”的增强项,第一版先不用纠结。
2.2 客户端主程序:只做检查不做替换
主程序里的 UpdateService 只负责两件事:定时检查服务端清单、把更新包下载到临时目录。它不碰正式安装目录里的任何文件。
原因很简单:主程序自己正被操作系统锁定,直接替换自己一定会失败,而且如果下载或校验过程中把正式文件改了一半,那用户连老的可用版本都没了。把检查、下载和替换拆开,任一环节失败都不会影响当前正在运行的应用。
检查更新的核心方法大概长这样:
csharp复制public async Task<UpdateManifest?> CheckForUpdatesAsync(
string serviceUrl,
string appId,
string channel,
Version currentVersion,
CancellationToken ct = default)
{
var url = $"{serviceUrl}?appId={Uri.EscapeDataString(appId)}" +
$"&channel={Uri.EscapeDataString(channel)}" +
$"&version={currentVersion}";
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(20);
var json = await http.GetStringAsync(url, ct);
var manifest = JsonSerializer.Deserialize<UpdateManifest>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (manifest is null) return null;
var latest = Version.Parse(manifest.Version);
var minimum = Version.Parse(manifest.MinSupportedVersion);
// 当前版本低于最低支持版本时,不能直接升级,需要提示用户重新安装
if (currentVersion < minimum)
throw new UnsupportedVersionException(manifest.MinSupportedVersion);
return latest > currentVersion ? manifest : null;
}
URL 里带上 appId、channel、version 这三个参数,是为了服务端可以做灰度分流和最低版本拦截。如果你的服务端是纯静态托管,没法解析参数也没关系,客户端拿到清单后自己用 minSupportedVersion 判断即可。示例里每次 new HttpClient() 是为了读起来简单,实际项目建议用 IHttpClientFactory 或一个长期复用的单例。
2.3 独立升级进程:为什么必须“自己杀自己”
替换文件这一步必须由独立的升级进程完成。这个进程不依赖主应用,可以单独启动,它要做的事情是:
- 等待主应用进程退出;
- 把临时目录里的新文件复制到正式安装目录;
- 把当前版本备份到备份目录,方便出问题时回滚;
- 重新拉起主应用。
主应用在准备升级时,通过命令行参数把必要信息传给升级进程:
code复制MyUpdater --app-dir "C:\Users\admin\AppData\Local\MyApp" \
--update-dir "C:\Users\admin\AppData\Local\Temp\MyApp\update-2.3.0" \
--backup-dir "C:\Users\admin\AppData\Local\MyApp\.backup" \
--main-exe "MyApp.exe" --pid 12345
Windows 上等待 pid 退出,用 Process.GetProcessById(pid).WaitForExit() 就能等;Linux 和 macOS 对任意 PID 的 WaitForExit 支持没那么稳,我在组件里用的是轮询 HasExited,每 500ms 检查一次,等到进程退出或超时再继续。之所以必须等,是因为 Windows 下主进程不退出,exe/dll 文件就是被锁死的,你同进程替换必然失败。
3. 核心实现:版本比对、哈希校验和原子替换的代码骨架
这一节放可以直接抄的代码。为了简单,下面的示例用的是 System.Version,实际如果你的版本号带预发布后缀,把它替换成 NuGetVersion 即可。
3.1 更新清单的模型定义
csharp复制public sealed class UpdateManifest
{
public int ManifestVersion { get; set; }
public string AppId { get; set; } = "";
public string Channel { get; set; } = "stable";
public string Version { get; set; } = "";
public string MinSupportedVersion { get; set; } = "";
public string PackageUrl { get; set; } = "";
public long PackageSize { get; set; }
public string PackageSha256 { get; set; } = "";
public List<UpdateFileInfo> Files { get; set; } = new();
}
public sealed class UpdateFileInfo
{
public string Path { get; set; } = "";
public long Size { get; set; }
public string Sha256 { get; set; } = "";
public bool Executable { get; set; } // Linux/macOS 上需要 +x 权限
}
MinSupportedVersion 这个字段很容易被忽略,但它实际上承担了一个“止损”职责:如果用户的老版本已经和新版的数据格式、接口完全不兼容,直接跳版本升级会得到一堆莫名奇妙的运行错误。最低支持版本会提示用户去下载完整安装包,而不是尝试做一次必然失败的增量升级。
3.2 下载:临时文件 + Range 断点续传
下载更新包时,组件永远先写临时文件。文件名带随机后缀或者版本号,例如 update-2.3.0-abc123.tmp,避免多个升级任务撞在一起。断点续传的实现不复杂,关键是 HttpRequestMessage 里带上 Range 请求头:
csharp复制private static async Task DownloadWithResumeAsync(
HttpClient http,
string url,
string tempPath,
long totalSize,
IProgress<double>? progress,
CancellationToken ct)
{
var fromBytes = File.Exists(tempPath) ? new FileInfo(tempPath).Length : 0;
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (fromBytes > 0)
request.Headers.Range = new RangeHeaderValue(fromBytes, null);
using var response = await http.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
// 服务端不支持 Range 时,会回 200 并从 0 开始返回,必须清空旧临时文件
if (response.StatusCode == HttpStatusCode.OK && fromBytes > 0)
{
await using var truncate = File.Create(tempPath);
fromBytes = 0;
}
await using var target = new FileStream(
tempPath, FileMode.Append, FileAccess.Write, FileShare.None);
await using var source = await response.Content.ReadAsStreamAsync(ct);
var buffer = new byte[81920];
int read;
var downloaded = fromBytes;
while ((read = await source.ReadAsync(buffer, ct)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), ct);
downloaded += read;
progress?.Report((double)downloaded / totalSize * 100);
}
}
需要注意:FileMode.Append 在断点续传时是对的,但如果服务端忽略了 Range 头、直接回 200 并从 0 开始返回内容,这时候旧临时文件里的前半段就变成了脏数据,所以必须先截断文件再续写。上面的代码里判断了 response.StatusCode == HttpStatusCode.OK,就是这个用途。
实际部署时我还加了限速。版本更新通常在用户休息或后台时段跑,但如果公司网络带宽很小,一两个大包就能把办公网打满。实现方式是在循环里按已下载字节数做流量控制,比如每 1MB 睡 100ms,具体阈值按自己的场景调。
3.3 SHA256 校验:下载完先算一遍再说
校验这一点上我踩过真坑:有一回更新包在 CDN 上没同步完整,客户端下载下来后压缩包能勉强解压,但里面有个 dll 是 0 字节。如果直接覆盖,会导致用户的应用在主界面都起不来。所以组件规定:下载完成后先校验整个包哈希,解压完成后还要按 files 数组逐个校验文件哈希。
csharp复制public static async Task<bool> VerifySha256Async(
string filePath,
string expected,
CancellationToken ct = default)
{
await using var fs = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(fs, ct);
var actual = Convert.ToHexString(hash);
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
}
使用方式是在解压完成后:
csharp复制foreach (var file in manifest.Files)
{
var fullPath = Path.Combine(updateDir, file.Path);
if (!await VerifySha256Async(fullPath, file.Sha256, ct))
throw new HashMismatchException(file.Path);
}
这个双层校验要多花一点时间,但对更新安全性的提升是决定性的。包级校验保证你下载的东西完整,文件级校验保证解压后的每个产物都正确,两者缺一不可。
3.4 原子替换与失败回滚:先备份,再替换
覆盖正式目录时,组件采用“备份-替换-回滚”三阶段。备份目录放在安装目录下隐藏文件夹 .backup,替换前先把现有的正式文件移动进去,然后再把新文件移动进来。如果替换到一半抛异常,就把备份文件原样挪回去。
csharp复制private static void ApplyUpdateWithBackup(
string appDir,
string updateDir,
string backupDir,
IReadOnlyList<UpdateFileInfo> files)
{
Directory.CreateDirectory(backupDir);
// 1. 备份当前正式文件
foreach (var file in files)
{
var src = Path.Combine(appDir, file.Path);
if (!File.Exists(src)) continue;
var dest = Path.Combine(backupDir, file.Path);
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
File.Move(src, dest, overwrite: true);
}
// 2. 移入新文件
try
{
foreach (var file in files)
{
var src = Path.Combine(updateDir, file.Path);
var dest = Path.Combine(appDir, file.Path);
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
File.Move(src, dest, overwrite: true);
// Linux/macOS 上设置可执行权限
if (file.Executable && !OperatingSystem.IsWindows())
File.SetUnixFileMode(dest,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
}
catch
{
// 3. 失败则回滚
foreach (var file in files)
{
var backup = Path.Combine(backupDir, file.Path);
var dest = Path.Combine(appDir, file.Path);
if (File.Exists(backup))
File.Move(backup, dest, overwrite: true);
}
throw;
}
}
这里有几个容易踩的细节:
- 备份时用
File.Move而不是File.Copy。Move是文件系统层面的操作,速度快,也不会出现旧文件备份到一半被新文件覆盖的中间状态。 - 临时目录默认放在
Path.GetTempPath()下,如果它和正式目录不在同一个磁盘,File.Move会退化成复制+删除,大文件替换耗时很长。所以对大更新包,我会先把解压目录放到正式目录旁边的临时目录里,保证同磁盘内的 rename 操作足够快。 File.SetUnixFileMode是 .NET 7 才提供的 API,如果你还在用 .NET 6,可以在替换后用Process.Start("chmod", "+x " + dest)绕过。
4. 跨平台实测:Windows、Linux、macOS 的差异化处理
组件写完第一版,我在三个平台上各跑了一轮,问题基本都出在我没想到的细节上。先用一张表总结容易踩的点,后面逐个说:
| 平台 | 文件锁定特点 | 权限/签名问题 | 重启方式 | 隐藏雷区 |
|---|---|---|---|---|
| Windows | exe/dll 被锁,替换必须等进程退出 | 装在 Program Files 需要提权 | 拉起 exe | 杀毒软件扫描锁、UAC |
| Linux | 运行中 inode 可换,新进程读新文件 | 解压后可能丢 +x 权限 | 直接执行;systemd 服务需要 systemctl restart | zip 默认不保存权限位 |
| macOS | .app 是目录 bundle,整体替换更安全 | 下载文件可能带 quarantine 属性 | 执行 Contents/MacOS 下主程序 | 签名被破坏、Info.plist 没同步 |
4.1 Linux:可执行权限会被悄悄丢掉
Linux 上最典型的坑是:从压缩包解压出来的可执行文件,默认没有 +x 权限。原因很简单,zip 格式本身能保存 Unix 权限位,但很多打包工具不会写这一项。如果你的应用是 dotnet publish 出来的自包含单文件程序,解压后直接执行,会报 Permission denied。
我在组件里用 files 数组里的 executable 标记来解决。每次替换文件后,对这个标记为 true 的文件显式设置 UnixFileMode。如果你用 systemd 托管这个应用,升级后还需要执行 systemctl restart 才能生效,这一点没法在组件内部完全代劳,所以升级进程支持一个 restart-command 参数,由打包配置指定重启服务的命令。
4.2 macOS:quarantine 属性和 .app 包结构
macOS 上第一次下载的包会被系统打上 com.apple.quarantine 扩展属性,也就是说,用户下载并解压出的应用,在没有签名的情况下第一次打开会被 Gatekeeper 拦截,右键打开才会出现“仍要打开”的选项。自动升级组件用 HttpClient 下载的文件,不会自动带这个属性,但如果你在测试时用了 curl 或 wget 下载,就有可能出现“我本地能跑,用户机器上跑不了”的诡异现象。
处理方式是在升级完成后,对安装目录批量移除 quarantine 属性:
code复制xattr -dr com.apple.quarantine "/Applications/MyApp.app"
或者在你的组件里用 Process.Start("xattr", ...) 执行。另外,macOS 的 .app 是个目录 bundle,如果只替换 Contents/MacOS 下的可执行文件,而 Contents/Info.plist 里的版本号和资源没更新,系统设置里显示的还是旧版本号。解决方案很简单:升级包内必须包含整个 .app 结构中会被读取的文件,不能只替换主可执行文件。
4.3 Windows:Program Files 里的权限是个大问题
Windows 最容易踩的坑不是技术而是权限。如果你的应用安装在 C:\Program Files\MyApp 下,普通用户没有写权限,自动升级组件想替换文件,必须提权。但提权会弹 UAC 窗口,用户体验很差,而且企业环境里很多用户根本没有管理员权限。
我的建议是:如果你的应用需要频繁自动升级,安装路径就用“每用户安装”,安装到 %LOCALAPPDATA%\Programs\MyApp(类似 Electron 应用的默认位置),这样组件可以直接写文件,不需要提权。如果你的产品由于历史原因必须装到 Program Files,那升级组件就得支持“辅助升级”——主程序把更新包下载好后,弹出一个需要管理员权限的升级进程窗口,让用户在升级那一刻确认 UAC,而不是在安装时一直占着管理员权限不放。
此外,Windows 上替换文件时会遇到杀毒软件的扫描锁。我有一次实机测试,新文件已经复制到了正式目录,但杀毒软件正在扫描它,导致随后的 File.Move 偶发失败。遇到这种情况,组件要做重试:文件操作失败后,等待几百毫秒再试,最多重试 5 次。
5. 生产环境踩坑:断点续传、回滚与灰度发布
走到这一步,组件已经能跑了。但真正放到生产环境,还会有一些“能跑但不好用”的问题,下面是我后来逐项补上的。
5.1 断点续传:网络差的时候用户才不会骂人
第一次上线时我以为下载只是一个小功能,结果被吐槽最多的就是它。原因是很多用户所在网络访问更新服务器很慢,几十 MB 的包下到一半断掉,组件从头再来,用户体验极差。
所以断点续传不是锦上添花,而是刚需。实现方式我在 3.2 节已经给了,这里要补充的是临时文件的清理策略:下载完成后,如果校验失败,不要把临时文件删掉,而是留着,同时写一个 .download.json 标记记录 URL 和已下载大小。下次检查更新时,如果发现同样 URL 的未完成临时文件,就直接续传。如果用户磁盘紧张,则要限制临时文件占用,比如超过 500MB 的旧临时文件一周清理一次。
5.2 静默更新还是引导式更新:取决于你的用户画像
组件最初默认“发现更新就后台静默下载,下载完提示重启”,后来我发现这个策略不是万能的。对于面向普通用户的工具类软件,很多用户不知道为什么应用突然要重启;对于企业内网部署的工具,“重启后我要重新打开一堆工作窗口”的抱怨更明显。
现在的做法是提供两种模式:
- 引导式更新(默认):发现新版本后弹窗说明更新内容和体积,用户点“立即更新”才下载,下载完提示“重启完成更新”。
- 静默更新(可选):适用于工具类应用和后台服务,下载和替换都静默进行,替换完成后调用重启命令。
对于桌面应用,我强烈不建议“下载完强制重启”,这属于把用户电脑当成自己测试机。
5.3 灰度发布:按用户 ID 哈希分配
全量发布风险最大的场景是:新版本在某个系统环境上有兼容性问题,一推出去所有用户都中招。灰度发布可以显著降低这个风险。服务端做灰度不需要复杂的功能,只需在更新接口里按用户特征分流。由于服务端是静态托管,所以分流逻辑放在客户端配合服务端:客户端把用户唯一 ID 一起放进 URL 参数,服务端根据哈希值决定返回哪个 channel 的清单。
csharp复制public static bool IsInGrayGroup(string userId, int percent)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(userId);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
var value = BitConverter.ToUInt64(hash, 0);
return value % 100 < percent;
}
比如 IsInGrayGroup(userId, 10) 返回 true,就给这 10% 的用户返回 beta 清单,其他 90% 返回旧的 stable。这样即使新版本有问题,受影响用户也控制在 10% 以内,回滚代价小得多。注意这里的哈希必须是稳定哈希,同一个用户每次算出来的分组要一致,否则用户会反复横跳在两个版本之间。
5.4 回滚:光有备份目录还不够,要加启动探活
3.4 节讲的备份可以在替换失败时回滚,但还有一类更隐蔽的失败:文件全部替换成功了,新版一启动就崩溃,甚至主界面都出不来。这种失败发生在替换之后,备份目录里的旧文件还在,但用户不知道该怎么回去。
我在组件里补了启动探活机制,核心是让升级进程充当 watchdog,而不是依赖主程序自检:
- 升级进程替换完文件后,先写
pending-update.json,记录当前版本和备份目录; - 拉起主程序,然后最多等待约 20 秒,观察主程序是否写入
startup-ok.flag; - 20 秒内写入了,说明新版本启动成功,删除标记文件,更新流程结束;
- 超过 20 秒没写入,说明新版本大概率起不来,升级进程从备份目录恢复旧版本,再拉起旧版本。
这个 20 秒的阈值要按应用实际冷启动耗时调整,设得太短会把正常启动慢的用户误判成失败。我在实际项目里还加了一个“失败计数”:连续两次探活失败才执行恢复,避免用户手动退出或网络慢造成的偶发误判。
最后分享一点维护这套组件半年多的体会:自动升级不是写一个“下载-替换-重启”的方法就结束了,它本质上是把发布流程的安全性下放到每个用户机器上。最值得投入精力的不是花哨功能,而是校验、回滚和灰度这三件事。只要你把这三件事做稳,绝大多数升级事故都能在用户无感的情况下自行消化。代码结构上,组件始终遵守“主程序不替换自己、任何失败不碰正式目录、升级前必须有备份”这三条铁律,后续加再多功能,也建议先守住这三条。
