做 .NET 桌面应用或工控上位机开发的朋友,应该都体会过发版后的那种无力感:新功能做完了,Bug 也修了,可后台日志一看,还有一大堆用户跑在旧版本上,客服只能一遍遍引导对方去官网重新下载安装包。要根治这个困境,最好的办法就是在应用里内置一个自动升级组件。今天要聊的,就是 .NET 生态里非常实用的一类开源跨平台自动升级组件:它负责自动检测新版本、下载更新包、校验文件完整性、替换程序并在失败时回滚,Windows、Linux、macOS 都能覆盖。对维护内部工具、行业客户端、边缘设备程序的人来说,这类组件几乎是刚需。
1. 自动升级到底在解决什么问题
1.1 用户为什么总是不更新:客户端发布的“最后一公里”之痛
做 Web 的同学可能很难理解桌面端发版有多痛苦。Web 改完直接上线,用户刷新一下就是新版本;桌面应用不行,你发布 2.0,用户机器上可能还跑着 1.0,甚至 0.9。以前我在做一个制造业车间的数据采集客户端时,程序部署到几十台工控机上,每次升级都要提着 U 盘一台一台跑,运气不好遇到产线不能停,还得等到休息间隙。后来项目规模大了,远程运维的机器越来越多,手动升级这条路彻底走不通。
这种“最后一公里”的问题,核心不在技术,而在分发方式:用户不会主动去关注你发了什么新版本,也不愿意走“下载安装包—关闭旧程序—手动安装—重新打开”这套流程。尤其对非技术用户来说,弹窗提示“有新版本”和让他自己处理,根本是两回事。自动升级组件要做的,就是把“发现新版本、下载、替换、重启”这一整套动作自动化,让用户几乎无感知地完成升级。
这也是为什么很多企业级软件、工具类应用,都会内置升级模块。它不只是省事,更重要的是能保证所有用户运行在受支持的版本上,不然线上问题永远没法收敛。对 .NET 开发者来说,社区里已经有了不少成熟的自动升级组件,不用重复造轮子,关键是选对方案、设计好流程。
1.2 一个能用的自动升级组件,至少要具备这 5 个能力
市面上自动升级组件不少,但真正“能用”的,至少要覆盖下面这 5 个环节。
版本检测。客户端按策略访问更新源,拉取最新版本信息,和当前版本做比较,判断是否需要更新。这个环节看着简单,坑其实很多,比如版本号比较规则、多发布渠道(稳定版/测试版)、检查频率,稍不注意就会出问题。
升级包下载。确定要升级后,下载更新包。这里要考虑网络中断、断点续传、下载进度反馈。别小看这一步,内网环境或者跨地区下载时,网络抖动是常态,一个下载失败就放弃,体验会非常差。
完整性校验。下载完成后,必须验证文件是否完整、是否被篡改。最常用的是 SHA256 哈希比对。我见过不少项目跳过这一步,结果在弱网环境下升级包下载损坏,解压失败,程序直接起不来。
程序替换与重启。把新版本文件替换到应用目录,然后重新启动进程。这是自动升级技术含量最高的地方,尤其是在 Windows 上,exe/dll 正在运行时根本没法覆盖,必须借助外部进程或脚本来完成。
失败回滚。升级后新版本启动崩溃,这时候如果没有任何保护机制,用户就会被卡在一个坏版本里。好的升级组件会自动备份旧版本,启动自检失败时切回上一版。
前 3 个是“能不能升”的问题,后面 2 个是“升坏了怎么办”的问题。很多项目只关注前 3 个,上线后遇到一次替换失败或启动崩溃,就不敢再自动升级了。所以在我眼里,回滚能力不是加分项,而是必须项。
1.3 开源和跨平台,对实际项目意味着什么
标题里有两个关键词:开源、跨平台。这两个属性对选择组件来说非常重要。
先聊开源。自动升级组件会直接接触应用目录、操作系统进程、文件权限,属于敏感模块。闭源组件你很难审计它到底做了什么,出问题也只能等官方修复。开源组件就灵活得多,代码放在那里,你可以读、可以改,发现 Bug 甚至可以提 PR 修复。我之前遇到一个组件在新版 macOS 上无法替换 .app 的问题,就是因为签名校验规则变了,开源项目里提了 issue 后很快有人给出了补丁,这种事闭源产品几乎不可能发生。
另外,很多企业需要内网私有化部署,开源组件意味着你可以把整个更新源全部搭在内网,不依赖外部服务,安全性和可控性都更有保障。还可以自己维护一个 fork,把特定行业的定制逻辑加进去,比如灰度发布的规则、特定目录结构等。
再说跨平台。今天 .NET 程序早就不是 Windows-only 了,.NET Core / .NET 5+ 让 Linux、macOS 都成了标准目标平台。工控领域、服务器工具、Mac 桌面应用里跑 .NET 的越来越多。如果自动升级组件只能处理 Windows,那你为了其他平台就还得再找一套方案,维护成本直接翻倍。跨平台的升级组件,能用一套代码、一套更新源,覆盖所有目标平台,这才是 .NET“一次开发、多平台部署”的完整版。
2. 组件选型:市面上有哪些能用的,我为什么这样选
2.1 主流 .NET 自动升级组件横向对比
网上能搜到的 .NET 自动升级方案不少,但成熟度差别很大。我整理了自己用过的、以及在社区里见到比较多的几个,做了一个横向对比。
| 组件 | 跨平台 | 更新协议 | 接入成本 | 维护状态 | 适合场景 |
|---|---|---|---|---|---|
| NetSparkle | Windows/Linux/macOS | appcast XML | 中 | 活跃 | 需要覆盖全平台、协议标准化 |
| AutoUpdater.NET | Windows 为主 | 自定义 XML/JSON | 低 | 一般 | Windows 桌面工具快速接入 |
| Onova | Windows/Linux/macOS | 自定义 manifest | 中 | 较活跃 | 需要更细粒度控制更新流程 |
| Velopack | Windows/Linux/macOS | 打包+更新一体 | 中 | 活跃 | 从零开始且希望打包更新一体的新项目 |
NetSparkle 在 .NET 社区里算是老牌方案,灵感来自 macOS 上的 Sparkle 框架,天然支持多平台。它最大的优势是更新协议基于 appcast,这个格式在 macOS 生态里验证了很多年,成熟稳定。缺点是对 .NET 新手来说,概念有点多,配置项也多,需要花点时间理解。
AutoUpdater.NET 走的是“简单粗暴”路线,拖个控件、配个地址就能跑,对纯 Windows 项目非常友好。但它的底层依赖 .NET Framework/Windows 的一些机制,做 Linux/macOS 支持时比较吃力,跨平台需求强烈时不推荐。
Onova 是一个非常轻量的框架,把更新流程拆成了检查、准备、应用几个阶段,你可以在中间穿插自己的逻辑,灵活性很高。它本身不限制平台,但正因为灵活,很多细节需要自己处理,适合喜欢掌控全流程的开发者。
Velopack 是这几年比较受关注的新方案,它不只是升级组件,还把 release 打包也一起管了。如果你是完全的新项目,希望一条龙搞定安装包生成和自动更新,Velopack 值得研究。但它的设计思路和传统方案不太一样,迁移老项目时需要评估改造量。
2.2 我选型的 4 个判断依据
选组件不能光看 GitHub Star 数,我通常会按下面 4 个维度去评估。
第一,平台覆盖范围是否满足项目规划。如果只做 Windows,AutoUpdater.NET 完全够用;如果已经确定会有 Linux 或 macOS 客户端,那就直接选择跨平台方案,不要留到以后再说。跨平台不是一个“是否支持”的布尔值,而要看每个平台的处理是否成熟。有些组件号称支持 Linux,但替换逻辑只是简单 mv,连权限都不处理,这种支持意义不大。
第二,更新协议是否简单可控。我不太喜欢那种更新源格式和代码强绑定的组件,最好更新协议就是一个清晰的 XML/JSON 文件,服务端想怎么生成都行。这样以后换组件、对接内部发布系统,成本都更低。NetSparkle 的 appcast 和 Onova 的自定义 manifest 都属于这类。
第三,社区维护活跃度。一个一年没发版的项目,遇到框架升级就只能自己改源码。看维护状态时,我会重点关注最近 release 的时间、issue 响应速度、以及是否兼容 .NET 6/8/9 这些当前 LTS 版本。组件本身开源,就意味着维护停滞时你还有自救的余地,但那是最后的选择。
第四,接入和定制成本。接入成本包括学习成本、代码侵入程度、以及后续定制难度。比如要在升级前弹窗提示变更日志、要支持灰度发布、要限制升级时间段,这些需求组件能不能低成本支持,需要提前想清楚。
我在实际项目里最后的选择是 NetSparkle 这套思路:协议用标准的 XML,更新源搭在自己服务器上,客户端在启动时静默检查,用户确认后下载更新。这样既保留了对流程的控制,又不至于自己重写一套文件替换逻辑。方案没有绝对好坏,关键是匹配项目现状和团队维护能力。
3. 核心实现细节:一个自动升级流程是怎么设计的
3.1 版本探测:从拉取更新清单到本地版本比对
自动升级的第一步,是让客户端知道“该升级了”。通常做法是客户端访问一个固定地址拉取更新清单,里面包含最新版本号、下载地址、哈希值等信息。这个清单格式我用的是 XML,类似 appcast 风格:
<?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"> <channel> <item> <title>MyApp 2.3.1</title> <sparkle:version>2.3.1</sparkle:version> <sparkle:releaseNotesLink>https://update.example.com/notes/2.3.1.html</sparkle:releaseNotesLink> <enclosure url="https://update.example.com/packages/app-2.3.1.zip" sparkle:edSignature="..." sparkle:dsaSignature="..." length="12345678" type="application/octet-stream"/> </item> </channel> </rss>客户端拿到清单后,第一件事是版本号比较。这里我吃过一次亏:一开始用字符串直接比,结果 1.10.0 被当成小于 1.9.0,导致用户反复收到升级提示。后面统一改用语义化版本比较,.NET 里可以用System.Version处理纯数字版本,如果版本号可能带 beta/rc 这类前缀,建议直接用 NuGet 上的SemanticVersioning或NuGet.Versioning。
检查频率也要控制好。我现在的做法是:应用启动后延迟 10 秒做一次检查,之后每 4 小时检查一次,避免一启动就抢网络资源。更新检查失败不能影响主程序启动,必须放到后台异步任务里,并且要有超时控制。有些做法是启动时强制同步检查,更新源一旦不可用,用户连主界面都进不去,这种设计我认为不可接受。
3.2 下载与完整性校验:别让损坏包毁掉一次发布
版本比对结果是需要升级,接下来就进入下载阶段。下载这块看着简单,其实有不少细节。
下载文件不能直接覆盖正式文件。我最常用的做法是下载到应用目录下的临时目录,比如updates/download/app-2.3.1.zip,下载完成后先校验再使用。下载过程要支持断点续传,用 HTTP 的 Range 头实现,避免大文件在弱网环境反复从头下载。进度信息要回调给 UI,让用户知道升级在进行,尤其是几十兆甚至上百兆的更新包,没有任何反馈用户会以为程序卡死了。
下载完成后,完整性校验是绝对不能省的一步。用更新清单里记录的 SHA256 和本地文件计算出的哈希做对比:
using var stream = File.OpenRead(packagePath); using var sha256 = System.Security.Cryptography.SHA256.Create(); var hash = Convert.ToHexString(sha256.ComputeHash(stream)); if (!string.Equals(hash, expectedSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("升级包校验失败,已中止安装"); }如果哈希对不上,直接丢弃文件并记录日志,不要重试,因为可能是更新源本身有问题,也可能是网络被劫持。这里还有一个进阶做法:对更新清单本身做数字签名,防止清单被篡改后指向恶意下载地址。签名校验需要提前在客户端内置公钥,实现成本不算高,对安全性要求高的场景值得加上。我自己维护内部工具时,这一层是用私钥对清单做签名,客户端启动时验签,对团队来说也就多了一条发布命令而已。
另外要注意磁盘空间。升级包解压后通常还要额外占用一倍空间,提前检查磁盘剩余空间,不够就友好提示,不要在安装到一半时磁盘写满,造成程序文件不完整。
3.3 跨平台文件替换:Windows 与 Linux/macOS 的差异处理
下载和校验只是准备工作,真正的重头戏是文件替换。核心难点在于:程序正在运行时,自己的文件是被系统占用的,Windows 上尤其严格,exe/dll 正在运行或被加载时根本无法覆盖。
我的做法是引入“外部进程”来完成替换。主程序只负责下载、校验、解压到暂存目录,然后把替换动作交给一个独立的小脚本或小进程。这样主程序可以先退出,外部进程等主程序完全结束后再执行文件复制。
Windows 下我用的批处理是这样:
@echo off set APP_DIR=C:\MyApp\app set NEW_DIR=C:\MyApp\updates\app-2.3.1 set BAK_DIR=C:\MyApp\backup timeout /t 3 /nobreak >nul taskkill /f /im MyApp.exe >nul 2>&1 if exist "%BAK_DIR%" rmdir /s /q "%BAK_DIR%" move "%APP_DIR%" "%BAK_DIR%" >nul xcopy /e /y "%NEW_DIR%" "%APP_DIR%" >nul start "" "%APP_DIR%\MyApp.exe"大概思路是:等待主进程退出,强制结束残留的 MyApp 进程,把旧版本目录移动到备份目录,再把新版本目录复制过去,最后启动新版本。这套逻辑虽然土,但非常可靠,很少出问题。注意 taskkill 之后要加一点延时,否则文件句柄可能还没完全释放。
Linux/macOS 下的替换脚本思路类似,但要注意执行权限。部署目录里旧文件的所有者可能是 root,升级进程如果以普通用户运行,就需要特别处理。我一般会让更新进程使用和主程序一致的服务账号,并在替换后重新设置权限:
#!/usr/bin/env bash APP_DIR=/opt/myapp NEW_DIR=/opt/myapp-updates/app-2.3.1 BAK_DIR=/opt/myapp-backup sleep 3 pkill -f MyApp || true rm -rf "$BAK_DIR" mv "$APP_DIR" "$BAK_DIR" mv "$NEW_DIR" "$APP_DIR" chmod +x "$APP_DIR/MyApp" chown -R myappuser:myappgroup "$APP_DIR" nohup "$APP_DIR/MyApp" >/dev/null 2>&1 &macOS 上如果应用是 .app bundle 结构,直接替换整个 .app 目录即可,但要注意签名和 Gatekeeper 的问题。签名应用被替换后,如果签名失效,新版本可能无法启动。解决办法是更新包里包含正确签名的应用,替换后不要额外改动签名相关的文件。开源的跨平台升级组件会在这些细节上帮你处理掉大部分差异,但你最好还是理解原理,排查问题时才不慌。
3.4 失败回滚:自动升级翻车后的救命稻草
前面说过,回滚不是加分项而是必须项。我设计回滚逻辑时用了非常简单但有效的“三目录 + 标记文件”策略。
具体思路是:应用目录保持一个稳定的current符号链接/目录名,实际版本放在带版本号的目录里。升级时,新的目录先变成current_new,旧目录保留为current_old。应用启动时读取一个启动标记文件,如果启动过程中发生严重异常(比如关键模块加载失败),标记文件记录连续启动失败次数;超过阈值,启动器自动把current切换回备份的旧版本目录。
简化后的规则大概是:
- 升级前,把当前版本目录完整复制或重命名为
backup。 - 升级成功后,写入
UPGRADE_OK标记。 - 应用下次启动时,如果 10 秒内没有收到“启动成功”的内部消息,就把连续失败次数加一,超过 2 次则触发回滚。
- 回滚时,把
backup目录恢复为当前版本,同时清理失败的新版本目录。
这个“启动成功”的内部消息可以由主程序在完成核心初始化后写入一个状态文件。比如数据库连接成功、主窗口正常加载后再标记。千万不要只等进程退出,因为进程崩溃也会导致状态文件没有更新。
回滚逻辑虽然简单,但能兜住大部分“新版本根本没起来”的情况。现实里我见过很多升级事故,不是下载环节坏了,而是新版本存在初始化崩溃、依赖缺失、配置不兼容等问题。有了回滚,这类故障最多让用户多等一次启动时间,而不是彻底无法使用。
3.5 全量包还是增量包:一次务实的取舍
更新包格式的选择,直接影响分发效率和开发复杂度。全量包就是把整个应用打包成 zip,简单直接,服务端生成容易,客户端解压替换也容易。增量包只包含相对上一版本变化的部分,省流量、省时间,但生成增量包的复杂度高,而且如果用户跳了多个版本,还得做“链式更新”或者“回退到全量包补丁”,容易出各种边界问题。
我的建议是:小工具、内部系统、打包后体积在几十兆以内的应用,无脑用全量包就行。用户下载量不大,带宽也不是瓶颈,全量包换来的是省心和稳定。
只有应用体积很大、更新频繁、用户量大到明显影响带宽成本的情况下,才值得做增量更新。 .NET 发布时可以开启压缩,单文件发布也会显著缩小包体积,这在一定程度上降低了增量更新的必要性。如果确定要上增量,也要保留全量包作为兜底:增量更新一旦失败,自动改为下载全量包。
另一个相关点是 .NET 的单文件发布。把应用发布成单文件 exe 后,文件结构会发生很大变化,升级组件的文件匹配逻辑也要相应调整。我踩过的一个坑是:单文件发布后,运行时会把相关内容解压到临时目录,导致升级组件在寻找“正在运行的进程对应的可执行文件”时定位错乱。所以在选择组件时,要看它是否对单文件发布做过适配,或者自己预留处理逻辑。
4. 实操过程:从零接入一个 .NET 项目
4.1 服务端发布物准备:更新源目录与 manifest 管理
接入自动升级,服务端先要搭一个简单的更新源。不需要复杂的后端服务,一个静态文件服务器就够。目录结构如下:
update-feed/ appcast.xml packages/ app-2.3.1.zipappcast.xml就是前面提到的更新清单,每次发版时手动或脚本生成。packages目录存放各版本的压缩包。文件名最好带上版本号,方便排查问题。
服务端准备流程一般是:
- 用
dotnet publish -c Release -r linux-x64 --self-contained发布对应平台版本。 - 把发布产物打成
app-版本号.zip。 - 计算压缩包的 SHA256,填入 appcast.xml。
- 上传到更新源服务器。
- 如果有多平台客户端,给每个平台准备独立的 appcast 文件,比如
appcast.win.xml、appcast.linux.xml。
更新清单的版本号必须和程序集版本、文件版本区分开。自动升级组件比较的是清单里的<sparkle:version>,主程序内部的 AssemblyVersion 可以按自己的规则走,两套可以一样,但建议在发布脚本里统一生成,避免人为不一致。我自己用发布脚本在 CI 里自动生成 appcast,版本号从 git tag 读取,这样基本杜绝了手写导致的错误。
4.2 客户端接入:最小可用的接入流程与关键代码
以 NetSparkle 风格为例,客户端接入其实没想象中复杂。先安装对应的 NuGet 包,然后在应用启动流程里初始化升级服务:
var sparkle = new Sparkle( "https://update.example.com/appcast.xml", icon: SparkleAppIcon.Default ); sparkle.StartLoop(true); // 在后台线程循环检查更新这段代码会让组件读取远端的 appcast.xml,在后台按配置的时间间隔检查更新。如果发现新版本,组件会弹窗提示用户,用户确认后开始下载、校验、关闭应用并执行替换。开发者需要做的,只是在合适的时机初始化这个服务。
如果组件用的不是这种“开箱即用”的模式,而是像 Onova 这样把流程拆成阶段,那接入代码会更接近下面这种结构:
var manager = new UpdateManager( "https://update.example.com/manifest.json", new JsonManifestParser() ); var result = await manager.CheckForUpdatesAsync(); if (result.HasUpdate) { var update = result.LastVersion; // 提示用户,确认后准备更新 await manager.PrepareUpdateAsync(update); // 应用更新,组件会处理进程退出和文件替换 manager.LaunchUpdater(); }无论用哪种组件,代码层面需要处理的通用部分有三个。第一是初始化时机,一定要在主程序核心模块加载完成后再启动检查,避免更新组件本身成为启动瓶颈。第二是异常隔离,更新检查的任何异常都不能冒泡到主流程,否则更新源一挂,主程序也崩了。第三是 UI 提示,至少要显示当前版本、新版本、更新内容简介、下载进度这几个信息,让用户对即将发生的事有预期。
4.3 测试与灰度:上线前必须做好的三件事
自动升级功能上线前,建议按下面的清单做一轮完整的测试。
第一,模拟完整的升级路径。准备一个旧版本安装包,把更新源指向测试地址,从旧版本一路升到最新版。重点验证文件替换后程序能否正常启动、配置和数据文件是否被覆盖、升级后版本号是否正确。这里要特别注意“数据文件”的处理:升级组件默认是整包替换,如果应用数据就放在程序目录里,会被一并替换掉,必须把用户数据目录单独规划。
第二,测试失败场景。包括:断网时检查更新、下载到一半断开、升级包哈希错误、新版本启动崩溃触发回滚。尤其是回滚测试,要人为制造启动失败,验证回滚逻辑是否真的能把旧版本捞回来。这个测试很容易被跳过,但一旦线上出问题,回滚就是最后的防线。
第三,灰度发布。即使内部测试全过,也不能一口气推给所有用户。我通常会在 appcast 里增加发布策略字段,比如rollout: 20,表示只让 20% 的客户端执行升级;跑一段时间观察反馈,再把比例逐步提到 100%。如果升级组件本身不支持灰度,也可以通过更新源层面控制:准备两个 appcast 地址,切换指向不同版本的清单,配合 DNS 或代理来做,虽然笨但很有效。
对于公网应用,还需要考虑老版本长期不升级的情况。一个实用的做法是设置“最低支持版本”的概念:低于某个版本的客户端,升级组件强制要求更新,不允许跳过,这样能避免线上同时存在大量跨度极大的版本,给维护造成负担。
5. 常见问题与排查技巧实录
5.1 高频问题排查速查表
实际使用中,大家遇到的问题高度集中。我把高频问题整理成速查表,方便以后对照排查。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 一直提示有新版本,但版本号一样 | 版本号比较用了字符串比较 | 改用语义化版本比较 |
| 下载到一半就失败,重试还是失败 | 网络不稳定,且没有断点续传 | 为下载增加 Range 断点续传 |
| 提示“文件被占用,无法替换” | 旧进程没有完全退出,或存在多实例 | 替换前等待并强制结束进程 |
| 升级后程序直接打不开 | 升级包损坏,或依赖缺失 | 校验哈希;升级失败自动回滚 |
| Linux 下提示没有权限 | 应用目录归属 root,升级进程权限不足 | 统一服务账号,替换后重置 owner 和权限 |
| 杀毒软件拦截升级进程 | 未签名的脚本/程序被安全软件误报 | 对升级程序签名,加入白名单 |
| macOS 上替换 .app 后签名失效 | 更新包签名不正确,或 Gatekeeper 拦截 | 确认发布产物正确签名,不要修改签名文件 |
| 检查更新非常慢 | 更新源在国外,或者检查超时设置太长 | 将更新源放到内网/CDN,设置合理超时 |
这几类问题里,最容易被忽视的是“多实例”。用户可能开了多个程序窗口,替换时只杀掉了其中一两个进程,剩下的进程继续占着文件。务必要用进程名全量结束,并且做二次确认,确认没有残留进程再开始替换操作。
5.2 我在真实项目里踩过的几个坑
前面写了很多方法论,这里分享几个我实际踩过的坑,希望你能绕开。
第一个坑就是版本号用字符串比较。当时发布 1.9.0 后紧接着发布了 1.10.0,结果所有 1.9.0 用户都收不到升级提示,因为字符串比较认为“1.10.0”小于“1.9.0”。排查了很久才发现是这个低级问题,从那以后我强制要求所有版本比较走语义化版本库。
第二个坑是 Windows 上直接自我替换。最早偷懒,想让主程序自己完成文件覆盖,结果 exe 正在运行根本删不掉,升级组件自己崩了,用户还停留在旧版本。后来老老实实引入外部脚本,问题才彻底解决。这个教训让我明白:升级过程中,主程序要足够“克制”,不要指望自己能替换自己,把盘交给外部脚本更可靠。
第三个坑是升级包没有校验哈希,测试环境网络抖动把 zip 下坏了,解压后程序缺文件,内网几百台机器一起坏掉。当时我还没做回滚机制,运维同事只能挨台机器手动重装,折腾了整整一天。那次之后我把“校验哈希”列成了发布流程里的硬性检查项,任何更新包不过哈希校验绝不执行替换。
还有一个容易忽略的点:证书和 HTTPS 的问题。内网环境使用的自签名 HTTPS 证书,客户端默认是不信任的,请求会直接失败。如果更新源是内网服务器,要么把证书部署到所有客户端,要么升级组件支持自定义证书校验逻辑。
写在最后
这几年的实践下来,我的体会是:自动升级组件最考验的不是“能跑通”,而是“坏了能恢复”。下载失败、校验不过、文件被占用、新版本启动崩溃,这些情况都可能发生,设计时就要把这些失败路径全部想到,而不是抱着“应该不会那么倒霉”的侥幸心理。我现在的项目里,升级成功率稳定在 99.5% 以上,靠的就是完整流程加回滚保障。
最后再分享一个小技巧:升级组件不要把检查逻辑写死为“启动后立即检查”,尽量延后几秒并放到后台线程,给主程序留出初始化时间,体验会好很多。另外,每次发布后自己先手动升一次,不要等用户做小白鼠。这个习惯我保持了三年,确实帮我拦下了好几次本会上线的低级事故。