十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于.NET的开源跨平台自动升级组件设计与实践

基于.NET的开源跨平台自动升级组件设计与实践 1. 自动升级这件事为什么值得自己做一套组件先聊个很实际的问题你的应用程序做完了功能、界面、性能都调好了接下来最容易被忽略、却又最影响用户体验的是什么答案是升级。我见过太多团队在产品上线后才开始头疼——用户还停在 1.0你修复了一批 Bug 发布了 2.1结果用户根本不知道或者知道也懒得去官网重新下载安装包。在这个场景下应用程序自动升级组件就成了刚需。它解决的不只是让用户用上新版本更是一整套关于版本分发、增量更新、失败回滚、安全校验的工程问题。尤其当你用的是 .NET 技术栈又需要考虑 Windows、Linux、macOS 多平台部署时这件事的复杂度会成倍上升。我做这套基于 .NET 的开源跨平台自动升级组件最初的动机其实很朴素公司内部有七八个内部工具需要频繁迭代手动分发安装包的效率实在太低。后来发现社区里虽然有现成的方案但要么绑定特定框架要么对跨平台支持不够完整要么配置复杂到让人劝退于是决定自己动手做一个轻量、务实、能真正落到生产环境的组件。这篇文章会把这套组件的设计思路、核心实现、踩坑记录和实际使用方式完整拆开讲。如果你是 .NET 开发者正在做桌面应用、服务程序或者哪怕只是维护一个内部工具这篇文章的内容应该能帮你少走不少弯路。2. 组件定位与选型分析为什么不做成单一框架的专属方案2.1 先明确要解决的问题边界在动手写第一行代码之前我需要先回答一个问题这个组件到底要承担什么职责把需求拆开来看主要集中在四点检测更新程序启动时或定时向服务端查询是否有新版本。下载更新能从指定地址下载更新包支持断点续传、校验完整性。执行更新在合适的时机替换旧文件这个过程往往需要跨进程操作。失败恢复更新失败不能把用户的系统搞坏要有回滚或重试机制。这四件事听起来简单但每一项展开都会遇到不少细节。比如替换文件时如果程序正在运行Windows 下文件是被锁定的Linux 下虽然可以覆盖但进程内存里还是老代码这些都需要绕过去。2.2 现成方案对比为什么我没有直接躺平社区里其实已经有一些相关项目比如我研究过的几个方案方案框架绑定跨平台支持配置复杂度更新策略ClickOnce.NET Framework 系桌面仅 Windows中受限的自动更新不适合非 MSI 场景Squirrel.Windows.NET 桌面仅 Windows高安装包级更新主打 WindowsVelopack.NET 6较好支持 Win/macOS/Linux中全量包和增量包都支持自研组件不绑定全平台低按需定制灵活控制Squirrel 和 Velopack 都是不错的项目尤其是 Velopack如果团队能用它的工作流体验相当好。但为什么我还要自研核心原因是我需要一个足够轻量、可以被嵌入到非标准部署场景比如绿色软件、服务端工具、内网离线环境的组件同时希望能完全控制更新协议便于跟公司内部的发布系统对接。自己做维护成本固然存在但换来的是极高的定制空间。2.3 跨平台的隐含要求跨平台三个字看起来简单实际落地时每一个环节都要重新思考文件路径处理Windows 用反斜杠Linux 和 macOS 用正斜杠Path.Combine 能解决大部分问题但有些库内部写死了分隔符。进程管理Windows 上结束进程、以管理员权限执行操作和 Linux 下 kill 进程、chmod 文件完全是两套 API。自更新限制Linux 上可以覆盖正在运行的可执行文件通过 inode 机制但 Windows 不行必须通过辅助进程来完成文件替换。网络环境Windows 环境可能走公司代理Linux 服务器可能完全离线下载源也不一样。这些约束直接决定了组件的架构设计后面会逐一展开。3. 整体架构与更新流程设计3.1 模块划分这套组件从逻辑上分成四大模块各自职责清晰尽量做到互不干扰更新检查器UpdateChecker负责向服务端发起版本查询请求解析版本号判断当前版本是否落后。下载管理器DownloadManager负责拉取更新包到本地临时目录并对进度、校验、断点续传做处理。更新执行器UpdateExecutor负责在合适时机执行文件替换需要处理进程锁、权限提升、辅助进程等问题。回滚管理器RollbackManager负责记录更新前的文件状态在更新失败时恢复到更新前版本。这四块各自独立意味着如果某一块有问题可以单独替换实现不会影响整体。3.2 一次完整的更新流程长什么样用一个具体场景来说明某台 Windows 机器上的桌面应用当前版本是 1.2.0服务端发布了 1.3.0。第一步应用启动后UpdateChecker 发起 HTTP 请求到配置好的 manifest 地址得到类似这样的 JSON 内容{ version: 1.3.0, releaseNotes: 修复了导出功能崩溃优化了启动速度, files: [ { path: app.dll, url: https://updates.example.com/v1.3.0/app.dll, hash: sha256:xxxx, size: 1048576 } ] }第二步组件将本地的 AssemblyVersion 或自定义版本号与服务端版本号做比较。这里需要特别说明一下版本比较逻辑不能简单字符串比较因为 1.10.0 在字典序上小于 1.9.0 但实际上是新版本。我实现了一个 SemVer 风格的比较器按主版本、次版本、修订号逐级比较。第三步如果判定需要更新DownloadManager 开始下载并实时计算文件哈希。下载完成后和服务端返回的 hash 比对不一致则视为下载失败直接删除重来避免解压或替换时遇到损坏文件。第四步UpdateExecutor 开始工作。如果是 Windows 环境主程序会启动一个轻量的辅助进程例如 UpdaterHelper.exe将替换清单传给它然后主程序退出。辅助进程等待主进程完全退出后将新文件逐个覆盖到应用目录完成后重新拉起主程序。第五步如果替换过程中出现异常RollbackManager 利用之前备份的旧文件进行恢复保证应用至少还能以旧版本运行。3.3 几个容易想当然的设计点设计这套流程时有几点很容易被忽视一是下载目录的选择。不能直接下载到安装目录因为安装目录可能没有写权限比如装在 Program Files 下。组件统一将更新包放到 Path.GetTempPath() 下的独立子目录避免权限问题和残留冲突。二是备份策略。不是把所有旧文件全部备份那太浪费空间了。我采用按文件列表备份的方式只备份将要被替换的文件并且备份到应用的独立 backup 目录下。如果应用有大量静态资源文件需要一起升级这种按需备份的策略能明显降低磁盘占用。三是日志。更新过程涉及跨进程协作一旦失败排查链条非常长。所以在主程序和辅助进程里都埋了结构化日志包括版本号、文件路径、下载耗时、校验结果等关键字段。没有日志的自动升级组件出了问题基本只能靠猜。4. 核心技术实现拆解版本比较、文件校验与进程协作4.1 版本号比较不只是一个字符串比较版本号解析是更新组件的基础如果这一层出问题后面全部白搭。我设计的版本号规则是标准的 x.y.z 格式但同时也兼容 x.y 和带前缀字母的场景比如 v1.2.3。public static bool IsNewerThan(string currentVersion, string newVersion) { var current ParseVersion(currentVersion); var latest ParseVersion(newVersion); if (latest.Major ! current.Major) return latest.Major current.Major; if (latest.Minor ! current.Minor) return latest.Minor current.Minor; return latest.Patch current.Patch; } private static (int Major, int Minor, int Patch) ParseVersion(string version) { var cleaned version.TrimStart(v, V); var parts cleaned.Split(.); var major int.Parse(parts[0]); var minor parts.Length 1 ? int.Parse(parts[1]) : 0; var patch parts.Length 2 ? int.Parse(parts[2]) : 0; return (major, minor, patch); }这里有个容易被忽略的细节如果服务端返回的版本号不合法比如不是数字直接抛异常是不合适的因为更新服务偶尔会配置错误不应该让主程序因为这个原因启动失败。我通常的做法是解析失败时记录警告日志并把它当作无需更新处理。还有一个策略层面的选择是否允许降级我设计了一个 UpdateOptions 配置项默认不允许降级即只有当服务端版本号大于当前版本时才执行更新。但在内网环境下有时候管理员需要把某个机器从测试版回退到稳定版这时可以将 AllowDowngrade 设为 true 强制回退。4.2 下载模块校验、断点续传与重试策略下载模块的核心要求是可靠。我最初版本只是简单地用 HttpClient 把文件拉下来后来发现生产环境中有太多意外情况用户网络不稳定下载到一半中断。公司代理服务器会缓存文件导致拉回来的是旧包。下载过程中磁盘空间不足文件写入失败。针对这些情况我做了三个改进。首先支持 Range 请求实现断点续传。下载器会记录已完成的字节数如果中断下次从断点继续而不是重新下载整个文件。这个功能在大型更新包几百 MB 以上时尤其有用。private async TaskStream DownloadWithResumeAsync( string url, string savePath, CancellationToken cancellationToken) { var fileLength new FileInfo(savePath).Length; using var request new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Range new RangeHeaderValue(fileLength, null); using var response await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); return await response.Content.ReadAsStreamAsync(cancellationToken); }其次每个文件都必须校验哈希。我选择 SHA256 而非 MD5因为虽然 MD5 速度快但在安全性和抗碰撞性上已经不够可靠。对大型文件采用分块计算哈希的方式避免一次性把整个文件读进内存。第三个是重试策略。对于可重试的异常如网络超时、5xx 状态码组件最多重试 3 次每次间隔指数退避1 秒、2 秒、4 秒。对于不可重试的异常如 404、哈希不匹配则直接终止更新流程避免不断消耗用户带宽。4.3 文件替换跨进程的更新执行器这是整个组件最核心的部分也是踩坑最多的地方。先明确一个问题为什么一定要跨进程在 Windows 上如果主程序自己更新自己用 File.Replace 替换正在执行的 .exe 文件一定会抛出 IOException因为文件被进程占用。解决办法是启动一个辅助进程来执行替换操作主进程退出辅助进程等主进程退出后开始替换完成后再拉起主进程。Linux 下情况略有不同可执行文件被运行后依然可以通过 inode 覆盖进程内运行的是旧 inode 上的代码。但为了避免诡异的状态我也统一走辅助进程方案保证三种平台行为一致。辅助进程的设计要点辅助进程要尽量小避免引入额外的依赖否则更新时一旦辅助进程自身缺文件就麻烦了。辅助进程通过命令行参数接收任务清单清单是一个 JSON 文件路径里面包含文件映射关系和主程序重启命令。辅助进程要能感知主进程退出状态或者轮询主进程进程列表直到确认退出再执行替换。public static int ExecuteUpdate(string manifestFilePath) { var manifest JsonSerializer.DeserializeUpdateManifest( File.ReadAllText(manifestFilePath)); foreach (var file in manifest.Files) { var targetPath Path.Combine(manifest.TargetDir, file.RelativePath); var backupPath Path.Combine(manifest.BackupDir, file.RelativePath); Directory.CreateDirectory(Path.GetDirectoryName(backupPath)); if (File.Exists(targetPath)) { File.Move(targetPath, backupPath, overwrite: true); } File.Move(file.DownloadedPath, targetPath); } return 0; }注意上面的代码先备份再迁移并不是直接覆盖而是将旧文件移动到备份目录再放入新文件。这样一旦中途出错备份还在回滚管理器可以直接把备份目录里的文件迁回来。4.4 回滚管理器更新失败的最后一根救命稻草更新失败的概率看起来不高但一旦发生用户面对的就是一个打不开的应用体验极差。所以回滚机制不能只是锦上添花而是必须存在。回滚策略我用了比较实用的方式把备份目录保留在应用目录的隐藏子目录中比如.updates/backups/20250215_120000。成功启动主程序并完成首次健康检查后才清理备份目录。如何定义成功启动如果只是启动辅助进程拉起主程序主程序可能因为缺依赖崩溃。我是这样处理的主程序启动时如果检测到更新标记文件会尝试向一个本地回环地址上报启动成功信号。辅助进程等待一段时间比如 30 秒如果收到成功信号返回码为 0此时清理备份如果没收到主程序稍后会自动调用恢复接口从备份目录回滚文件然后再次启动。这个机制虽然不如复杂的 A/B 灰度专业但对于中小型应用的更新场景来说性价比非常高。5. 跨平台支持的关键细节与实测对比5.1 路径分隔符与大小写敏感问题Linux 和 macOS 的文件系统是大小写敏感的而 Windows 的 NTFS 默认大小写不敏感。这意味着同一个文件在 Windows 上访问 App.dll 和 app.dll 可能指向同一个文件但在 Linux 上就是两个文件。我在做跨平台测试时发现 manifest 里的文件路径如果大小写不统一在 Linux 上执行替换就会产生重复文件。解决方法是生成文件清单时始终使用应用实际运行时的相对路径并且所有路径统一用/分隔在写入目标路径时再用 Path.Combine 转换。此外Linux 上文件权限是必须处理的。如果新文件没有可执行权限755更新后的主程序无法正常启动。在打包更新包时我会在 manifest 中为每个文件声明权限位写入文件后调用 File.SetUnixFileMode 恢复权限。5.2 管理员权限和 UAC 的处理Windows 下如果应用安装在 Program Files 目录普通用户没有写权限更新时会失败。目前组件的策略是检测当前进程是否有目录写权限如果没有就尝试通过辅助进程请求提升权限。辅助进程的 manifest 文件会声明 requireAdministrator这样当主进程启动辅助进程时UAC 弹窗会出现在用户面前。这里有一个体验上的细节频繁弹 UAC 会让用户烦躁。我参考社区经验做了静默更新优化——如果应用设置在非受保护目录比如用户目录或 D 盘自定义目录则完全不需要 UAC。默认推荐安装到用户目录下既不需要提权又能保证更新流程无感。Linux 和 macOS 下则没有 UAC 概念依赖的是文件系统权限。如果目标是/usr/local这类系统目录辅助进程需要以 sudo 方式运行但为了用户体验我建议大多数工具类应用尽可能安装到用户可写的目录。5.3 跨平台实测三种系统的表现差异我在本地分别搭建了 Windows 11、Ubuntu 22.04 和 macOS Ventura 三个测试环境跑同一套集成测试整理出的对比表测试项WindowsUbuntumacOS文件替换需辅助进程主进程必须退出可直接覆盖运行中的 exe但建议辅助进程同 Linux可直接覆盖权限弹窗UAC 弹窗sudo 终端输入系统钥匙串授权弹窗路径处理大小写不敏感大小写敏感需统一规范默认大小写不敏感但 APFS 可以开启敏感执行权限无需设置 x需设置 x断点续传正常正常正常整体感受是Linux 和 macOS 在文件替换环节比 Windows 更好处理但在文件权限和路径一致性上反而需要更小心否则很容易在测试机上一切正常、跑到服务器上就出诡异问题。6. 实际使用教程三步集成到你的 .NET 项目6.1 第一步安装引用并做基础配置组件编译为 .NET Standard 2.0 目标因此可以兼容 .NET Framework 4.6.1、.NET Core 3.1、.NET 5 以及 .NET 6/7/8。安装方式很简单dotnet add package AutoUpdater.Core安装完成后在主程序入口处配置更新服务var updater new UpdaterBuilder() .UseManifestUrl(https://updates.example.com/manifest.json) .UseCurrentVersion(1.2.0) .SetDownloadDirectory(Path.Combine(Path.GetTempPath(), myapp_updates)) .EnableHashVerification() .EnableAutoBackup() .Build(); var checkResult await updater.CheckForUpdatesAsync(); if (checkResult.IsUpdateAvailable) { await updater.DownloadUpdatesAsync(checkResult.UpdateInfo); await updater.ApplyUpdateAsync(checkResult.UpdateInfo); }需要注意的一点是UseCurrentVersion 不要硬编码。我通常用程序集版本号或自定义的版本文件这样版本每次发包时不需要改源码。一般来说读取 EntryAssembly 的版本号var version Assembly.GetEntryAssembly() .GetCustomAttributeAssemblyInformationalVersionAttribute() ?.InformationalVersion;6.2 第二步服务端发布清单组件本身不限定服务端技术manifest 只要是一个可访问的静态 JSON 文件即可。但你需要在发布流水线中生成它。我在内部用的是 GitHub Actions PowerShell 脚本每次打 tag 时自动扫描构建产物、计算 SHA256、生成 manifest 并上传到静态文件服务器。一个关键经验manifest 里的文件列表必须是增量而不是全量否则每次更新都要下载所有文件。如果你的应用有大量静态资源图片、音视频、配置文件全量更新的代价会非常大。组件支持两种模式全量模式files 里为所有文件的列表适合小型应用或首次安装。增量模式files 里只包含发生变化或新增的文件适合有大量静态资源的中大型应用。后台会自动比对服务端文件和本地已有文件只下载缺失或哈希不一致的文件这样能节省大量带宽。6.3 第三步调用更新的时机更新的触发时机很讲究。我的建议是桌面应用启动时后台静默检查发现新版本先下载到临时目录等用户下次关闭应用时执行替换。不要让用户每次启动都等更新完成。服务端工具可以做成命令行触发或者定时轮询。有 GUI 的工具提供检查更新按钮和自动检查更新开关让用户有控制感。如果更新包下载好了但用户一直不退出程序也不要无限等待。可以设置一个提示窗口新版本已下载完成将在退出时自动更新。 用户看到更新内容说明后一般会主动重启应用完成更新体验比强推好很多。6.4 启动参数与命令行模式为了让辅助进程能独立完成更新组件预留了一个命令行模式MyApp.exe --updater-run --manifest /path/to/updates/xxx.json当主程序调用 ApplyUpdateAsync 时会启动这个命令行模式并传入任务清单。如果你不需要 GUI完全可以在纯命令行工具中使用同样的机制。7. 踩坑实录这些坑我帮你们趟过了7.1 下载完成但哈希总是对不上这是最开始频繁遇到的问题。排查后发现问题不在下载器而在代理服务器。有些企业网络环境强制走代理代理会缓存大文件导致客户端拿到的文件和源服务器上的不一致。解决办法有两个方向一是在 HTTP 请求头中带Cache-Control: no-cache并给 URL 加版本号或时间戳参数二是对哈希校验失败的文件不自动重试而是记录信息并提示用户检查网络代理设置。我实际采用了二者结合的方式。7.2 更新后主程序启动崩了有一段时间更新成功率很高但用户反馈更新后应用打不开。最后定位到原因是依赖项没有随主程序一起替换。如果主程序引用了某个 DLL 插件而插件有独立的版本号manifest 里只更新了主程序而没有同步更新插件就会出现程序集加载失败。解决方式版本发布时把所有运行时依赖DLL、配置文件统一归入更新包而不是只更新主程序 exe。同时组件在启动时增加程序集加载检查如果发现类型加载异常自动触发一次回滚不要等到用户手动反馈。7.3 辅助进程被杀导致更新中断辅助进程在执行中途如果被任务管理器杀掉或系统重启会造成文件状态不一致。有些文件已经替换有些还是旧版本。我的加固方案是在辅助进程执行替换前先将本次更新涉及的所有文件记录到事务日志里包括操作类型和目标路径。辅助进程每次启动时先检查事务日志如果发现未完成事务优先回滚旧文件再尝试重新执行更新。这个设计参考了数据库事务的思想虽然多了一些代码但在稳定性上提升很明显。7.4 内部工具用户还停留在远古版本除了技术问题还有一个真实的业务问题很多人不重启应用所以永远不触发检查更新。后来我加了一个强制更新开关。服务端 manifest 里标记minimumVersion如果当前版本低于最低版本应用启动后不允许进入主界面直接弹出更新进度条强制完成更新才能使用。这个开关我建议谨慎使用只在对兼容性有硬性要求时开启。但它在内网工具场景下非常有效基本上能保证所有用户的版本不会差距太大。8. 发布与贡献为什么建议你把它融入自己的项目这套组件我已经开源在 GitHub 上仓库地址放在项目文档里。内部我用它管理了三个生产系统包括一个 Windows 桌面客户端、一个 Linux 服务端辅助工具还有一个 macOS 内部脚本工具稳定运行了大半年更新了几十个版本还没有出现过一次无法恢复的更新事故。如果你也想在自己的项目里接入自动升级我的建议是先不要急着写代码。先梳理清楚你的发布流程、目标平台的部署习惯、用户对更新的容忍度然后再决定是全量更新还是增量更新、要不要支持强制更新、备份策略怎么定。想清楚方案后再把组件引进来改动量很小基本就是按照上面三步集成。对于有兴趣参与开源贡献的朋友组件里有几个方向特别适合入手增加更多平台的测试覆盖比如 ARM64 架构的 Windows/Linux。完善断点续传的并发测试。提供一个配套的 Web 管理面板用于生成和分析更新包。支持从 Git 仓库直接生成更新包。我个人在实际维护中最大的体会是自动更新组件不是一次性写完就完了的东西它要和你的发布流程、用户习惯、网络环境长期磨合。不要追求一上来就完美先让它在非核心工具上跑起来然后逐步把稳定性做到生产可用。这比花一个月时间闭门造车最后做出来却不敢上生产要可靠得多。
返回列表