资讯详情

资讯详情

基于JSON配置的.NET桌面应用自动更新方案实战

做 .NET 桌面应用的人迟早会被一个问题逼疯明明改好了一个 bug客户那边却还在用上一个版本怎么解释都没用。我刚把第一个 WinForms 小工具发给同事内测时就是这种状态后来换 WPF 写正式版、分发给几十台机器手动更新的成本直接把人压垮——每发一版就要挨个远程、拷文件、杀进程对方没空配合就得等。所以当基于 JSON 配置的自动更新方案跑通之后我第一反应是这件事真应该早点做。这篇文章想把整套思路和坑都摊开讲清楚。它不是那种给你一个类库就能自动更新的营销文而是从需求分析、清单设计、代码实现到线上踩坑的完整记录适合正在用 WinForms、WPF、WinUI 或其他 .NET 桌面框架做产品分发的人参考。看完你至少能照着写出一套可用的更新流程更重要的是能避开那些看起来能跑、实际发给用户就出事的暗坑。1. 自动更新这件事为什么非做不可1.1 一个让我下决心的崩溃瞬间当时我做了一个内部数据清洗工具WPF 写的打包成单文件 exe 发给三个部门用。第一版有语法错误修完发第二版时有人还在用第一版跑数据结果导出一堆错误结果数据入库之后才发现版本不对。那一刻我意识到桌面应用只要脱离了自己的机器版本控制就成了产品问题不是代码问题。手动更新的本质是让用户替开发者做运维这对技术同事还算友好但业务人员根本不会管你发了几个版本。他们会觉得能打开、能跑就是最新版。所以自动更新不是锦上添花而是桌面应用的刚需尤其当你的用户基数超过 10 个人、或者你没法随时站在他们屏幕前盯着时。1.2 为什么选择 JSON 配置而不是其他方案桌面应用的自动更新方案不少常见的有方案优点缺点JSON 配置文件驱动简单直观、跨平台、任意 HTTP 服务器都能托管需要自己写下载和替换逻辑数据库记录版本号查询灵活可关联用户状态桌面端直连数据库风险大不适合公网部署专门更新组件库如 Velopack、Squirrel功能完整安装/回滚都帮你做了学习成本高部分库对单文件发布支持一般手动下载安装包逻辑简单用户不配合等于没有更新我选 JSON 配置的核心原因就两条一是任意静态文件托管就能用不需要搭复杂后端二是格式透明出问题用记事本打开就能排查测试阶段能极大减少扯皮。而 .NET 生态里解析 JSON 的成本也接近于零System.Text.Json开箱即用完全没必要引入重量级组件。当然JSON 方案也有代价它只是更新清单下载、校验、替换、回滚都得自己实现。但这也意味着每一步都可控不会出现框架替我做了我却不知道它怎么做的的失控感。2. 更新清单的 JSON 结构字段定不好后面全是坑2.1 一份能跑起来的最小更新清单先别想复杂能跑通是最重要的。我的第一版更新清单长这样{ latestVersion: 2.3.1, minimumVersion: 2.0.0, mandatory: false, packageUrl: https://updates.example.com/app-2.3.1.zip, packageHash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, packageSize: 102400, releaseNotes: 修复数据导出时日期格式错误的问题 }讲一下每个字段的用途latestVersion是当前发布的最新版本号用于和本地程序集版本做比较。minimumVersion是最低允许运行版本。如果用户版本低于它客户端必须强制更新否则功能可能因为接口或数据结构变化而不可用。mandatory表示本次更新是否强制。通常是false但遇到不更新就坏事的变更时设为true。packageUrl指向更新包的实际下载地址。packageHash是更新包的 SHA256 值用于校验文件完整性。packageSize是包体积客户端可以先检查大小避免下载到一半才发现空间不足。releaseNotes是更新说明客户端可弹出窗口展示给用户看。这份清单背后有一个原则更新流程只需要读一个固定 URL比如https://updates.example.com/update.json。不要把版本号塞进前端 URL否则每次发版都要改服务端配置和客户端代码等于把简单问题复杂化。2.2 版本号比较为什么字符串比较一定会出事很多人第一次写检查更新会这么干string remoteVersion json.latestVersion; string localVersion Assembly.GetExecutingAssembly().GetName().Version.ToString(); if (remoteVersion ! localVersion) { // 有更新 }这段代码看运气。一旦版本从2.9.0升到2.10.0字符串比较会认为2.9.0比2.10.0大因为字符9的 ASCII 码大于1用户就永远收不到更新。正确做法是用System.Version类型比较它能正确识别三段、四段版本号var remote Version.Parse(manifest.LatestVersion); var local Assembly.GetExecutingAssembly().GetName().Version; if (remote local) { // 需要更新 }另一个minimumVersion的判断也要用Version它解决的是用户落后太多版本不能再增量升级的场景。比如当前最新版是 2.3.1但用户还停在 1.0.0中间的大版本变更可能改动了数据库结构或者配置文件格式这时候直接覆盖升级反而会出错不如强制走完整安装包。2.3 增量更新和多包支持提前留好扩展位一开始可以只有一个packageUrl但随着产品复杂会出现资源文件单独更新、不同操作系统用不同包的情况。我的经验是哪怕现在用不上也建议设计成包数组{ latestVersion: 2.3.1, minimumVersion: 2.0.0, mandatory: false, packages: [ { channel: stable, target: win-x64, url: https://updates.example.com/app-2.3.1-win-x64.zip, sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, size: 102400 } ], releaseNotes: ... }这样以后加了win-arm64、osx-x64这些目标服务端只需要在 JSON 数组里加一项客户端按当前运行时标识去匹配就行不用改代码。同理如果将来要做增量更新只需要增加一个deltas数组单独提供差异文件包完全不会破坏现有逻辑。3. 核心更新链路从检查版本到安全替换的四步流程3.1 第一步用 HttpClient 拉取远端 JSON 并解析检查更新的入口通常放在主程序启动时但要注意一点不要在主线程做网络请求否则遇到网络延迟用户会看到窗口白屏几秒钟体验极差。我一般用异步方法启动时后台检查有更新再弹提示。public class UpdateChecker { private static readonly HttpClient _httpClient new HttpClient { Timeout TimeSpan.FromSeconds(15) }; public async TaskUpdateManifest FetchManifestAsync(string manifestUrl, CancellationToken ct) { HttpResponseMessage resp await _httpClient.GetAsync(manifestUrl, ct); resp.EnsureSuccessStatusCode(); await using Stream stream await resp.Content.ReadAsStreamAsync(ct); UpdateManifest? manifest await JsonSerializer.DeserializeAsyncUpdateManifest(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive true }, ct); if (manifest null || string.IsNullOrEmpty(manifest.PackageUrl)) { throw new InvalidDataException(更新清单字段缺失); } return manifest; } }补充说明几个细节PropertyNameCaseInsensitive true能忍受服务端字段命名风格不一致省得因为packageUrl和PackageUrl的命名差异解析出空对象。HttpClient建议声明为静态复用不要每次 new否则高频率请求下会耗尽 socket 端口。超时时间设 15 秒是合理值既保证慢网络下能接收到响应又不会让用户等太久。如果超时就当作没有更新处理不能因为更新服务挂了导致主程序无法启动。这里有个安全原则更新检查失败不应该阻塞用户使用软件。宁可让用户继续跑旧版也不能让主程序因为检查更新异常而崩溃。后文讲容错时会具体展开。3.2 第二步版本比对和下载更新包拿到清单后先做版本比较再决定是否进入下载流程。这里我增加了一个下载前先确认的交互非强制更新时弹窗让用户选强制更新则只提示必须更新否则无法继续使用。public async Taskbool ShouldUpdateAsync(UpdateManifest manifest) { Version remote Version.Parse(manifest.LatestVersion); Version local Assembly.GetExecutingAssembly().GetName().Version; if (remote local) { return false; } if (manifest.Mandatory) { return true; } // 非强制更新询问用户 MessageBoxResult result MessageBox.Show( $发现新版本 {manifest.LatestVersion}是否立即更新, 软件更新, MessageBoxButton.YesNo, MessageBoxImage.Question ); return result MessageBoxResult.Yes; }下载更新包时我做过一次很傻的实现直接WebClient.DownloadFile没有超时控制没有重试。结果用户网络抖动一次下载就失败还得重新点更新。后来换成了带重试机制的流式下载public async Task DownloadPackageAsync(string url, string destPath, IProgressdouble progress, CancellationToken ct) { int retryCount 3; for (int attempt 1; attempt retryCount; attempt) { try { using HttpResponseMessage resp await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); resp.EnsureSuccessStatusCode(); long totalBytes resp.Content.Headers.ContentLength ?? -1; await using Stream source await resp.Content.ReadAsStreamAsync(ct); await using FileStream dest new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None); byte[] buffer new byte[81920]; long readTotal 0; int read; while ((read await source.ReadAsync(buffer, ct)) 0) { await dest.WriteAsync(buffer.AsMemory(0, read), ct); readTotal read; progress?.Report(totalBytes 0 ? (double)readTotal / totalBytes * 100.0 : 0); } return; } catch (Exception ex) when (attempt retryCount) { await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); } catch (Exception ex) { throw new IOException($更新包下载失败{ex.Message}, ex); } } }这块有几个值得注意的点HttpCompletionOption.ResponseHeadersRead是必须加的否则GetAsync会等待整个文件下载完才返回进度条就废了。重试间隔按 2 秒、4 秒递增避免在服务端已经过载时再集中重试。下载必须落到临时目录比如Path.Combine(Path.GetTempPath(), AppUpdater)不要直接覆盖正在运行的主程序文件。3.3 第三步SHA256 哈希校验不校验等于裸奔下载完成后第一件事不是解压而是校验哈希。哈希的作用有两个一是确认文件在传输过程中没有被损坏二是防止下载服务器被劫持时拿到恶意文件。public bool VerifyPackageIntegrity(string filePath, string expectedHash) { using var stream File.OpenRead(filePath); byte[] hashBytes SHA256.HashData(stream); string actualHash Convert.ToHexString(hashBytes); return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase); }校验失败时删除临时文件并返回更新包损坏请重试。不要试图修复或继续使用因为你根本不知道文件被改了什么。有一次我漏掉了哈希校验更新包在服务器上没传完服务商同步迟滞用户下载到的 zip 只有预期大小的一半解压直接抛异常还要杀进程恢复数据。自那以后我把无哈希不更新写死到了代码注释里。说到安全还有一个和哈希同等重要的环节下载 URL 的证书校验。如果你的更新走 HTTPS.NET 默认会校验证书这是好事。但不要为了让内网测试方便就全局跳过证书校验否则等于给中间人攻击敞开了大门。测试环境可以配白名单生产环境必须严格走系统证书链。3.4 第四步解压、备份、替换然后重启这一步是整个更新器最容易被写砸的地方因为 Windows 不允许直接覆盖正在运行或正在被加载的 exe/dll。你没法让主程序把自己替换掉所以需要拆成两个角色主程序负责下载、校验、解压到临时目录。更新器进程一个独立的小 exe负责备份、替换、清理、拉起主程序。更新器启动后工作流是等待主进程退出或者直接杀掉主进程建议优雅退出先发消息再超时强杀。读取更新配置获取临时目录里的新版本文件列表。将旧版本关键文件复制到备份目录至少备份主 exe 和配置文件。用新版本文件覆盖旧文件。启动主程序。如果覆盖或启动失败从备份恢复。核心替换逻辑用File.Replace或先删后拷都行但File.Replace更稳妥因为它能保证替换的原子性File.Replace( newFilePath, // 新文件路径 currentFilePath, // 被替换的文件路径 backupFilePath, // 备份文件路径 ignoreMetadataErrors: true );File.Replace会把当前文件移动到备份路径再把新文件放到当前位置。一旦中途断电至少还留有备份不至于连旧版本都没了。有一类特殊情况如果更新包是自解压 exe 而不是 zip逻辑也类似只是不需要解压步骤更新器直接执行安装包然后等待安装进程结束再拉起主程序。区别在于 zip 方案可以根据文件清单精确替换而 exe 安装包通常会把程序写到 Program Files 下权限要求更高反而麻烦。所以单文件绿色发布的项目我推荐 zip 方案需要装服务的再考虑安装包。4. 线上环境最常见的崩溃现场与排查链路4.1 failed to deserialize the json body 和 unexpected end of json input这两个报错是更新清单解析阶段的高频问题。前者从字面看是 JSON 字段缺失后者是 JSON 内容不完整。我遇到的一次实际场景是运维在服务商网页上编辑 update.json手滑没保存完整文件就发布上去了。客户端解析时直接抛出JsonException更新流程终止。排查链路如下先本地跑一次curl https://updates.example.com/update.json肉眼检查 JSON 是否完整。用 JSON 格式化工具校验格式发现结尾缺了反花括号。修复服务端文件后客户端测试恢复正常。这一个经历让我养成了两个习惯一是服务端加一个简单的校验发布脚本里用jq或者 PowerShell 的ConvertFrom-Json先解析一遍 update.json解析失败就不允许发布。Get-Content update.json | ConvertFrom-Json | Out-Null二是客户端在解析失败时不要直接崩溃而是记录日志并回退到不更新分支。用户看到的现象只是没有收到新版本提示而不是软件打不开。4.2 下载中断与ERR_INCOMPLETE_CHUNKED_ENCODING有用户反馈说更新每次都失败而且报错信息和浏览器里见到的net::ERR_INCOMPLETE_CHUNKED_ENCODING很像。这个错误本质是 HTTP 响应在传输过程中被截断了客户端拿到的 body 比声明的长度短或者 chunked 编码没有结束标志。最初我怀疑是 HttpClient 实现问题后来抓包发现下载大文件时公司网关会对长时间连接做空闲超时。而我的代码里没有在传输过程中发任何额外请求网关以为连接闲置了就主动断开。解决办法是给网络请求加更细致的读超时或者在传输期间用进度回调维持连接活性。实际上ReadAsync如果一直有数据流连接不会真的空闲问题往往出在服务器端首次响应慢或者反代超时时间设置得比客户端短。排查时可以检查服务端反向代理的proxy_read_timeout适当调大。客户端下载时不要只等首次响应要在循环里做超时判断。综合处理后我把漫长的等待时间都交给重试机制消化用户感知到的只是进度条偶尔回退但最终都能下载成功。4.3 文件被占用导致替换失败这是桌面应用更新中最经典的坑。用户开着 WPF 程序后台某个线程持有 DLL 句柄更新器杀完主进程但 DLL 可能还没立即释放。你用File.Replace或File.Delete就会抛异常。我的经验是不要只杀进程就立刻替换等待几百毫秒或者循环检测目标文件是否能打开。如果替换失败不要急着回滚尝试重试 3 次间隔 1 秒因为某些句柄释放有延迟。更根本的做法是让主程序关闭时不加载多余的 DLL尽量在入口程序集里保留核心逻辑避免主进程一退出还有一堆僵尸 DLL占着文件。一个更稳的措施把更新器设计成在主程序退出之后由主程序拉起更新器等待主程序完全退出WaitForSingleObject或轮询进程是否存在后再操作文件。这样能避免主程序还在启动过程中就被杀了的竞态。4.4 杀毒软件误报更新器比病毒还像病毒更新器进程的职责是下载文件、解压、替换可执行文件、拉起程序这套行为和木马几乎完全一致。所以第一次编译后发给用户WinDefender 或者其他杀毒软件直接隔离了更新器 exe用户更新直接失败。这个问题没有 100% 的解法但可以大幅降低误报概率用 Authenticode 代码签名证书对主程序和更新器签名杀毒软件对已签名的可信程序信任度更高。不要用压缩壳、混淆器这些手段会提高误报率。更新器逻辑保持简单不要自我复制、不要修改注册表、不要添加计划任务。这些操作一出现杀毒软件很容易判定为恶意行为。我的更新器只做三件事替换文件、备份、启动主程序。注册表一概不动路径只用应用程序目录连管理员权限都是万不得已才申请。5. 容错、回滚与误报处理更新器不能把用户的电脑搞坏5.1 更新器与主程序分离但更新失败要静默我前面提到更新器是独立进程这里再展开说说为什么。除了解决文件占用问题分离还有个好处更新器可以在主程序修坏的情况下独立工作。主程序负责拿更新更新器负责应用更新。主程序不管更新器执行结果如何都不能影响下次启动。这就要求主程序 Download 阶段异常 - 弹提示检查更新失败继续正常使用。更新器替换阶段异常 - 尝试回滚回滚失败也要留下日志等下一次启动再提醒。用户不是你的 QA他们不会在报错弹窗里点复制错误详情后发给你。所以所有可能失败的节点都要写日志。我一般把日志写到%LOCALAPPDATA%\AppName\updater.log按日期滚动并且在发生关键错误时把最近几行日志一并留在崩溃现场目录里。5.2 本机缓存与服务器挂了还能用旧版看起来好像是废话——服务器挂了当然用旧版但去掉服务器依赖之后很多桌面应用启动时会卡在检查更新请求上。如果超时设置不合理甚至可能让用户等 30 秒才能打开主界面。我的做法是启动时异步检查更新主界面立即显示不阻塞。检查更新失败时进入离线模式弹一个小提示但绝不阻止使用。把最后一次成功拉取的 manifest 缓存到本地下次启动如果远端不可达就告知用户当前为本地缓存版本信息。如果你做的是消息推送类或需要强一致数据协议的产品minimumVersion必须接管用户版本过低时即使更新服务器一时不可达也要给出明确提示而不是让程序带着旧数据结构跑起来然后功能全部异常。5.3 回滚策略好的回滚是用户无感的回滚回滚听起来复杂其实最核心的动作只有两个备份和恢复。每次应用更新前更新器先创建一个backup文件夹把将要被替换的文件复制进去。然后再写一个本次更新版本号 时间的标记文件记录当前应用的版本信息。更新完成后如果主程序在 5 秒内没有退出或者用户在下一次启动时检测到版本异常就可以提示检测到上次更新可能失败是否回滚。使用File.Replace天然支持这个流程因为backupFilePath就是现成的回滚源public void RestoreBackup() { if (File.Exists(backupFilePath)) { File.Copy(backupFilePath, currentFilePath, overwrite: true); } }不要做回滚就万事大吉的假设。回滚后用户的数据库文件、配置信息可能是新版本写入的旧的 exe 未必再能读取。所以我的建议是回滚只适合在文件替换阶段失败时自动进行如果主程序已经启动并写入了数据就只能人工介入不要自动回滚否则可能二次伤害数据。5.4 把更新做成主程序功能的一部分还是独立服务最后聊一个架构决策更新逻辑放在主程序里还是做成独立的更新服务。我的结论是对中小型桌面应用来说更新器做成独立小工具即可不要做成常驻后台服务。理由有三更新器只需要在有更新和替换文件时运行常驻服务会引来杀毒软件的额外关注。后台服务需要管理员权限安装绝大多数业务软件根本用不到这个权限。独立 exe 的更新器可以放在主程序目录下随主程序一起分发部署成本最低。而主程序里要做的是什么时候去检查、怎么告诉用户有更新、下载进度如何展示这些交互逻辑。把交互和替换拆开你调试的时候也方便没有更新器也能跑主程序没有主程序也能单独测替换流程。6. 最后再分享几个实践中的体会更新器这套代码我维护了两年多改过最多次的不是下载逻辑而是如何让用户不被打扰。用户真正关心的不是版本号而是程序能不能正常工作。所以我后来加了一个策略更新过程尽量在用户不常用机器的时段进行比如启动后 30 秒再检查或者用户空闲时下载但绝不强制打断当前操作。强制更新的弹窗只用于真正会破坏数据结构的版本变更其余时间的更新提示都做成可关闭的小气泡。实测下来用户的配合度反而高了很多——因为提示频率低了而且每次更新完确实没有出过问题信任感就建立了。还有一个细节是更新包体积。我最初的包把整个发布目录一股脑打进 zip后来发现很多静态资源文件根本不需要更新包从 80MB 缩到 6MB。方法是在更新清单里加一个changedFiles列表只打包变化的文件并在解压后按列表覆盖。这个优化对用户体验的提升非常明显尤其在内网带宽有限的场景下。如果你准备在项目里落地这套 JSON 配置更新方案建议先把最简单的链路跑通固定 URL 的 update.json、一个 zip 包、SHA256 校验、文件替换。这些做好后再考虑增量、多平台、断点续传这类进阶能力。自动更新最重要的是稳定、可回滚、可观测功能花哨反而是次要的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →