Skip to content

필사 모드: 如何构建可更新的解决方案:从安装向导到安全的自动更新

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 安装只是开始

真正的问题,从把软件交到用户手里的那一刻才开始。首次安装只发生一次,但更新会伴随产品存活的整个生命周期。要修复 bug、添加功能、堵住安全漏洞,就必须把已经部署出去的成千上万份安装安全地替换成新版本。

本文会把两件事放在一起讲。一是用户最先接触到的安装向导(wizard)的 UX 设计,二是在其背后默默运行的自动更新机制。无论是桌面应用、移动应用还是 Web PWA,构建"可更新的解决方案"的原理都出奇地相似:下载、验证、原子替换、失败则回滚。这四步就是全部。

第 1 部分 — 安装向导的 UX

多步骤流程与逐步验证

向导是把复杂配置拆成多个步骤的 UI。它不是把所有选项一股脑塞进一个屏幕,而是一步步引导用户。好的向导都有一套共同的骨架。

  • 逐步验证(per-step validation):在进入下一步之前,立即验证当前步骤的输入,而不是把所有失败都攒到最后一起爆发。比如在选择安装路径的步骤,就要当场确认该路径是否有写权限、空间是否够用。
  • 进度显示与返回(progress and back):要展示当前处于第几步、总共有几步,并允许用户自由返回上一步。返回时不应丢失已经填写的内容。
  • 合理的默认值(sensible defaults):大多数用户只会保持默认值一路点"下一步"。所以默认值实际上就是大多数人的真实配置。默认值应当是最安全、最常见的选择。

预览、演练与失败回滚

安装或更新会改变系统。在用户点击"应用"之前展示将会发生什么变化,能提升信任感。

  • 演练/预览(dry-run/preview):在真正动手改动文件之前,先汇总展示哪些文件会被新增、修改或删除。服务器配置向导可以先展示"用这份配置尝试连接"的结果。
  • 失败回滚(rollback on failure):如果安装过程中途失败,绝不能留下一个半安装状态。就像事务一样,要么全部成功,要么完全恢复到原状。

可恢复性

大文件下载到一半网络可能中断,用户也可能不小心关掉窗口。每次都要求从头再来,只会让用户疲惫不堪。可恢复性(resumability)是指保存中断时刻的状态,重新打开后能从那个点继续。下载靠 HTTP Range 请求续传,向导的输入值则临时存在本地。

第 2 部分 — 更新机制

语义化版本与更新渠道

要管理更新,首先得系统地给版本编号。语义化版本(SemVer)把版本号拆成 MAJOR.MINOR.PATCH 三段。兼容性被破坏时升 MAJOR,新增功能时升 MINOR,只修 bug 时升 PATCH。客户端凭这条规则就能判断:这次更新是可以放心的小修补,还是需要谨慎对待的大变动。

在此之上再加上更新渠道(update channel)。同一款产品也可以分出 stable(稳定)、beta(测试)、nightly(每日构建)等多条流,让新版本先流向愿意承担风险的用户。大多数用户留在 stable,早期采用者则转到 beta。

全量更新 vs 增量/差分更新

最简单的做法是全量更新(full update):把新版本整个下载下来直接替换。实现简单,但如果应用有几百 MB,哪怕只改一行代码,每次也要重新下载几百 MB。

增量/差分更新(delta/differential update)消除了这种浪费。只下载旧版本与新版本之间的差异(diff),在本地合并。有几个常见工具值得一提。

  • bsdiff:从两个二进制文件的差异中生成一个小补丁。是被广泛使用的经典方案。
  • Courgette:Chrome 团队的方案,在汇编层面分析可执行文件,生成的补丁比 bsdiff 小得多。它利用了这样一个事实:重新编译可能让地址整体偏移,但实际的代码改动其实很小。

增量更新能大幅节省带宽,但补丁只有在"上一个版本恰好就是那个版本"时才适用。所以通常要么准备多份从各个旧版本通向最新版本的补丁,要么让增量更新在失败时自动回退到全量更新。

原子替换与回滚

更新过程中最危险的时刻,是真正替换文件的那一刻。如果在删除旧文件、写入新文件的中途断电会怎样?原子替换(atomic swap)正是为了防止这种情况。

核心思路是"先把一切完全准备好,最后再一次性切换"。把新版本整个安装在旁边(比如 v2 文件夹),验证通过后,再把指向"当前版本"的指针(符号链接或配置项)从 v1 一次性切到 v2。借助文件系统的原子 rename,在任何一个瞬间,用户看到的要么是完整的 v1,要么是完整的 v2,不存在一半新一半旧的中间态。出了问题,只需把指针指回 v1,回滚就完成了。

  /app/current  ->  /app/versions/1.4.0   (通过原子 rename 切换)
                    /app/versions/1.5.0   <- 已预先安装并验证完成

  切换后:
  /app/current  ->  /app/versions/1.5.0
  失败时把 current 指针指回 1.4.0 即完成回滚

分阶段/金丝雀发布

如果把新版本同时推给所有用户,潜藏的 bug 就会一次性影响到所有人。分阶段/金丝雀发布(staged/canary rollout)可以防止这种情况。先只对 1% 的用户发布,观察错误率和各项指标;没问题的话再逐步扩大到 5%、25%、100%。就像矿井里的金丝雀一样,少数用户先察觉到危险。一旦出现异常迹象,就立刻暂停或回退发布。

后台下载,重启时生效

让用户停下来盯着进度条看,是很糟糕的体验。成熟的更新器会在后台悄悄下载(background download),准备就绪后只提示"将在下次重启时生效"。等用户下次重新打开应用时,再顺畅地切换到已经下载好的新版本。Chrome、VS Code 以及不少 macOS 应用都采用这种方式。

第 3 部分 — 完整性与安全

更新渠道对攻击者来说是一个极具吸引力的目标。如果能把伪造的更新推送出去,攻击者就能在用户的每一台设备上执行代码。因此完整性与安全不是可选项,而是必需品。

代码签名与签名·哈希验证

代码签名(code signing)是指开发者用自己的私钥给发布物附加数字签名。客户端用公钥验证该签名,以确认这份文件确实来自那位开发者,并且在传输过程中没有被篡改。

铁律是先验证再应用(verify before applying)。先检查下载下来的补丁的哈希与签名,只有通过之后才继续安装。如果顺序反过来(先应用后验证),那就已经太迟了。

TUF 的核心思路

TUF(The Update Framework)是一个专门设计用来防御针对更新系统本身的攻击的框架。它不依赖单一签名密钥,而是设置了角色分离的多把密钥(root、targets、snapshot、timestamp),即便其中一把密钥泄露,整个体系也不会因此崩溃。哪怕只借鉴其中几个核心思路,也能带来很大帮助。

  • 角色分离与密钥轮换:把签名权限拆分开来,一旦密钥泄露就能替换。
  • 新鲜度保证(freshness):用时间戳元数据确认"刚拿到的这份元数据是不是最新的",从而阻止把陈旧信息伪装成最新信息的攻击。

HTTPS、证书锁定与防止降级

  • HTTPS 与证书锁定(pinning):更新必须通过 HTTPS 获取,在重要场景下还应把服务器证书锁定(pinning)进应用里,让中间人攻击变得更困难。
  • 防止降级攻击(downgrade attack):攻击者可能重新推送一个签名依然有效的旧版本、有漏洞的版本,以此复活已知的漏洞。要防止这种情况,客户端绝不能降到低于当前版本,并且要确认服务器给出的版本号并非被回退过的。

第 4 部分 — 生态工具

没必要重新造轮子。各个平台都已经有成熟的更新工具。

  • Squirrel:面向 Windows 和 macOS 的更新框架。擅长增量更新和静默的后台安装。
  • Sparkle:macOS 应用事实上的标准更新器。通过一种叫做 appcast 的 RSS/XML feed 通知新版本信息,并内置签名验证。
  • electron-updater:负责 Electron 应用的自动更新,内部同时使用 Squirrel 与 appcast 风格的 feed。
  • Tauri updater:基于 Rust 的 Tauri 应用的官方更新器,默认提供签名验证与更新清单(manifest)。
  • OS 包管理器:在 Linux 上,apt、dnf 这类系统包管理器会把更新、依赖、签名验证整体接管过去,应用自己动手做更新器的必要性也随之降低。
  • 移动端应用内更新(in-app updates):类似 Android 的 In-App Updates API,可以在通过应用商店的同时,在应用内引导灵活/立即更新。
  • Web/PWA Service Worker:Web 上刷新页面就等于更新,但 PWA 可以通过 Service Worker 的更新生命周期,精细地控制"新版本已就绪 → 下次访问时激活"。下面会给出一个示例。
  • 服务器主导的功能开关/远程配置(remote config):无需重新部署代码,只靠服务器下发的开关就能打开/关闭功能,或做渐进式发布。这是发布与即时回滚的另一条轴线。

第 5 部分 — 更新时的数据迁移

变化的不只是代码本身。本地数据库或配置文件的格式(schema)也会随之演进。这里正是事故高发地带。

  • Schema/版本迁移:给数据打上版本号,应用启动时按顺序执行迁移脚本,从"当前数据版本"迁移到"目标版本"。迁移必须是顺序执行、逐步累积的。
  • 幂等迁移(idempotent migration):同一份迁移脚本跑两次,结果必须一致——因为迁移中途失败后重启是常有的事。要像"如果列不存在就添加"这样先判断条件再执行。
  • 前向/后向兼容性(forward/backward compatibility):为了给回滚留后路,最好让旧版本也能(哪怕不完美地)不崩溃地读取新版本写入的数据。新增字段时设为可选,与其立刻删除某个字段,不如先在一段时间内忽略它更安全。

示例:一个小型更新清单

服务器用来告诉客户端"最新版本是什么、去哪里下载、哈希值是多少"的清单,通常长这样。

{
  "channel": "stable",
  "latest": "1.5.0",
  "minimumSupported": "1.2.0",
  "releasedAt": "2026-07-03T09:00:00Z",
  "artifacts": [
    {
      "platform": "darwin-arm64",
      "url": "https://updates.example.com/app/1.5.0/app-arm64.zip",
      "sha256": "9f2c1b7e0a4d6f8b3c5e7a9d1f2b4c6e8a0d2f4b6c8e0a2d4f6b8c0e2a4d6f8b",
      "size": 41231884,
      "signature": "MEUCIQD...base64-signature..."
    },
    {
      "platform": "win32-x64",
      "url": "https://updates.example.com/app/1.5.0/app-x64.nupkg",
      "sha256": "1a3c5e7b9d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c",
      "size": 39882140,
      "signature": "MEQCIF...base64-signature..."
    }
  ],
  "delta": {
    "from": "1.4.0",
    "url": "https://updates.example.com/app/1.4.0-to-1.5.0.patch",
    "sha256": "7b9d1f3a5c7e9b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d"
  }
}

客户端通过 HTTPS 获取这份清单,用 minimumSupported 阻止降级,下载对应的 artifact 后验证 sha256signature,通过后再原子替换。

示例:Service Worker 更新

在 PWA 中,Service Worker 会像"已安装的应用"一样被缓存,所以需要一套流程来通知用户新版本并顺畅切换。下面是一个最小示例:当新的 Service Worker 进入等待状态时通知用户,用户同意后立即激活它。

// 页面侧代码
navigator.serviceWorker.register("/sw.js").then((reg) => {
  reg.addEventListener("updatefound", () => {
    const newWorker = reg.installing;
    newWorker.addEventListener("statechange", () => {
      // 新 worker 安装完成,且旧 worker 仍在控制页面时
      if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
        // 该向用户展示"新版本已就绪"提示条的时机
        showUpdateBanner(() => {
          // 用户点击"立即更新"后,让等待中的 worker 立即激活
          newWorker.postMessage({ type: "SKIP_WAITING" });
        });
      }
    });
  });
});

// controller 发生变化时(=新 worker 已激活)只刷新一次
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
  if (refreshing) return;
  refreshing = true;
  window.location.reload();
});
// sw.js — Service Worker 侧代码
self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "SKIP_WAITING") {
    // 跳过等待状态,把这个 worker 立即晋升为激活 worker
    self.skipWaiting();
  }
});

self.addEventListener("activate", (event) => {
  // 一旦激活,立刻接管所有已打开标签页的控制权
  event.waitUntil(self.clients.claim());
});

决策清单

在新项目里敲定更新策略时,值得问自己以下几个问题。

  • 分发路径:走应用商店/包管理器,还是自建更新器?可能的话优先考虑平台标准方案(Sparkle、Squirrel、apt/dnf、应用商店的应用内更新)。
  • 更新体积:应用是否大到需要增量更新?如果用增量更新,一定要同时准备全量更新作为兜底。
  • 应用安全性:原子替换与回滚是否已经就绪?是否绝不会留下半安装状态?
  • 完整性:是否在应用之前验证签名与哈希?是否阻止降级?是否走 HTTPS?
  • 发布:是否先对少数用户金丝雀发布并观察指标?是否有一键回滚的开关?
  • 渠道:是否要拆分 stable/beta 渠道?功能开关能否在不发布代码的情况下开关功能?
  • 数据:schema 迁移是否幂等、是否按顺序执行?回滚时旧版本能否承受新数据?
  • 体验:是否在后台下载、重启时生效?向导是否具备逐步验证、返回、断点续传?

结语

可更新解决方案的本质其实很简单。下载、验证、原子替换、失败则回滚。 在此之上,再叠加体贴用户的向导 UX(逐步验证、预览、可恢复)、安全扩大范围的发布方式(金丝雀、渠道)、抵御攻击的完整性机制(代码签名、TUF、防降级),以及应对数据演进的迁移方案(幂等、顺序、兼容)即可。

每个平台都已经有不错的工具:Sparkle、Squirrel、electron-updater、Tauri、Service Worker、OS 包管理器。重要的是理解这些原理,并挑选出与自己产品的风险和规模相匹配的组合。安装只是开始,而良好的更新体验才是让产品长久存活下去的关键。

参考资料

현재 단락 (1/130)

真正的问题,从把软件交到用户手里的那一刻才开始。首次安装只发生一次,但更新会伴随产品存活的整个生命周期。要修复 bug、添加功能、堵住安全漏洞,就必须把已经部署出去的成千上万份安装安全地替换成新版本。

작성 글자: 0원문 글자: 7,261작성 단락: 0/130