
简介面向CS系统开发人员的Delphi自动升级客户端源码解决软件发布后客户端版本更新繁琐的问题。资源围绕检测新版本、启动升级程序的完整流程实现了客户端以设定间隔轮询服务器版本号、发现新版本后调起升级程序完成更新的关键机制整体采用常见且稳妥的轮询升级方案。压缩包共56个文件约3.52MB包含pas源码、dfm窗体、dproj工程、dcu编译单元以及可直接运行的exe示例其中界面、下载、文件信息读取等模块划分清晰便于对照学习和二次开发。目前已有668人学习下载适合需要为自家CS系统搭建升级通道、熟悉Delphi网络编程与更新逻辑的中高级开发者。通过研究源码可掌握客户端定时向服务器请求版本信息、发现新版本后调用升级程序完成更新的实现思路并可直接修改复用其中的核心单元缩短升级功能开发周期。1. 自动升级不是“下载新 exe”是“把自己换掉”很多 Delphi 项目的“自动升级”实际长成同一个样子发布新版本时给客户传一个压缩包让对方手动解压覆盖。程序启动后不会检查有没有新版等三五个现场陆续反馈“装的还是旧版”才意识到升级流程本身才是交付链上最短的木板。自动升级要做的事非常具体程序启动时到服务器拉一份版本清单发现新版就按清单下载文件替换旧文件最后把自己重新拉起来。这套流程在 Delphi 里并不需要引入多少重型组件TNetHTTPClient加一个 JSON 解析器就能覆盖主链路真正难的是“正在运行的 exe 怎么换掉自己”——Windows 的文件锁会把所有想当然的方案挡在门外。这篇文章从探测协议讲到替换窗口再落到回滚和校验给的是能在 Delphi Community Edition 里直接编译的写法刚入门 Delphi 的开发者做一遍这个题目等于把文件 I/O、HTTP 请求、进程管理三个基本功完整串了一遍。2. 先定版本探测协议为什么 JSON 清单比 version.ini 可靠2.1 升级链路的三段式结构任何自动升级方案无论用 Delphi、C# 还是 Go 实现拆开看都是三段探测版本、下载文件、替换并重启。先想清楚三段之间的数据契约再落代码比直接写下载循环要省事得多。探测这一段的输入是服务器上的一份清单文件输出是“本地是否需要升级”和“需要更新哪些文件”。最简陋的做法是服务器放一个version.ini里面只写一个版本号字符串客户端读回来和本地配置比较。这个方案对只换一个 exe 的小工具够用但撑不过第二次迭代当交付物里多了配置文件、资源 dll、语言包时客户端只知道“有新版本”却不知道哪些文件发生了变更最终只能把整个目录重新拉一遍。全量拉取在小体积时没问题一旦资源文件上了几百 MB带宽和时间成本就会反噬体验。所以更可靠的探测协议是用一份 JSON 清单描述完整交付状态客户端拿到它以后既做版本判断也做文件差量判断。2.2 升级清单字段怎么设计常见做法的清单结构如下{ version: 1.2.3, build: 456, force: false, releaseNote: 修复导出报表时日期格式错乱, files: [ { path: app.exe, size: 5242880, sha256: 6f1d3... }, { path: resources/res.zh-CN.json, size: 20480, sha256: 9a03b... } ] }这份清单的核心价值在files数组每一项都声明了相对路径、字节数和 SHA256 哈希值。客户端拿到它之后可以逐条比对本地文件跳过哈希一致的文件只下载真正变化的几个。对于大多数内部管理系统version字段和force字段就够驱动升级策略了releaseNote是给用户弹窗看的放不放取决于产品要求。字段类型作用versionstring三段式版本号用于主版本判断buildint内部构建号同一版本下的递增序列forcebool是否强制升级为 true 时不提供“稍后再说”按钮files[].pathstring相对 app 所在目录的路径不能允许绝对路径和..越界files[].sizeint文件字节数用作下载前后的一致性预检files[].sha256string小写十六进制哈希替换前的完整性依据清单本身只是一个静态文本文件放在 Nginx 或对象存储上即可不需要服务端业务逻辑。CDN 也能直接托管客户端按 URL 拉取就行。2.3 全量升级与增量升级的边界协议虽然是同一个但“下载范围”可以分两种策略。单 exe 小工具直接全量客户端发现版本号不同下载app.exe替换自己即可。对于带资源目录、插件目录、报表模板的应用增量可以显著降低每次升级的流量判断“哪个文件变了”的依据就是清单里的 SHA256。增量方案的坑在于“跨版本跳跃”。某个现场停在 1.0.0你最新的是 1.3.0如果 1.3.0 的清单里只列举了从 1.2.0 升级时变更过的文件1.0.0 客户端按这份清单执行后会缺文件。规避方式有两个一是在服务端维护“每个旧版本对应一套增量清单”二是客户端做链式升级先升到 1.1.0 再升到 1.3.0。我一般建议选前者因为后者要求所有旧版本永久可访问运维负担大。在交付体量没有超过 200 MB 之前直接全量下载反而最省心。2.4 版本号比较的边界情况版本号比较不能直接用字符串比较函数。CompareStr(1.10.0, 1.9.0)会得到正数因为字符串里1的下一字符是.与9比较时.小于9结果完全错误。需要按点号分段转整数再逐段比较。uses System.SysUtils, System.Types; function CompareVersion(const ALeft, ARight: string): Integer; var LPartsA, LPartsB: TArraystring; i, LMaxLen, LValueA, LValueB: Integer; begin LPartsA : ALeft.Split([.]); LPartsB : ARight.Split([.]); LMaxLen : Max(LPartsA.Length, LPartsB.Length); for i : 0 to LMaxLen - 1 do begin if i LPartsA.Length then LValueA : StrToIntDef(LPartsA[i].Trim, 0) else LValueA : 0; if i LPartsB.Length then LValueB : StrToIntDef(LPartsB[i].Trim, 0) else LValueB : 0; if LValueA LValueB then Exit(LValueA - LValueB); end; Result : 0; end;函数返回正数表示 A 比 B 新负数表示更旧0 表示相等。三个细节需要注意缺失的段按 0 处理所以1.2等于1.2.0StrToIntDef把非数字段容错为 0版本号里出现脏数据时不会抛异常Trim是为了处理清单里手工编辑留下的空格。这个函数在每次启动时都会执行放在公共单元里复用即可。3. 用 TNetHTTPClient 拉取清单并做差量下载可直接抄写的 Delphi 代码3.1 为什么选 TNetHTTPClient 而不是 TIdHTTPDelphi 里最常见的两个 HTTP 客户端是 Indy 的TIdHTTP和 XE8 引入的TNetHTTPClient。前者历史悠久同步阻塞模型写起来直白但 TLS 底层依赖 OpenSSL 动态库部署到客户现场时常出现“版本对不上”的报错后者是 NetHTTP 体系默认走 WinHTTP 或系统 TLS 栈省掉了随程序分发 DLL 的麻烦。对于自动升级这种只做 GET 下载的场景TNetHTTPClient在 Delphi Community Edition 里直接可用不需要额外安装第三方控件这也是对入门者最友好的一条路径。3.2 拉取并解析清单的函数下面这个函数负责从服务器取回清单并解析成TJSONObject。它假设服务器地址存放在配置文件里留一个ABaseUrl参数是为了测试时可以指向本地。uses System.Net.HttpClient, System.Net.URLClient, System.JSON, System.SysUtils; function FetchManifest(const ABaseUrl: string): TJSONObject; var LHttp: TNetHTTPClient; LResponse: IHTTPResponse; LBody: string; begin Result : nil; LHttp : TNetHTTPClient.Create(nil); try LHttp.UserAgent : AutoUpdater/1.0; LHttp.ConnectionTimeout : 5000; LHttp.ResponseTimeout : 30000; LResponse : LHttp.Get(ABaseUrl /manifest.json); if LResponse.StatusCode 200 then raise Exception.CreateFmt(升级清单返回异常: HTTP %d, [LResponse.StatusCode]); LBody : LResponse.ContentAsString; finally LHttp.Free; end; Result : TJSONObject.ParseJSONValue(LBody) as TJSONObject; if Assigned(Result) then Exit; raise Exception.Create(升级清单不是合法 JSON); end;TNetHTTPClient的实例需要显式Free而Get返回值是接口类型IHTTPResponse不需要手工释放这是和TIdHTTP的一个明显差异。ConnectionTimeout是建连超时ResponseTimeout是等待响应的总超时自动升级发生在程序启动阶段两个值都不宜设太大否则网络异常时用户会盯着空白窗口等很久。返回的TJSONObject由调用方负责释放。3.3 比对本地文件并下载变更项拿到清单之后遍历files数组逐个比对哈希只下载缺失或变化的文件。下载目标不是最终目录而是临时目录留到替换阶段再统一处理。uses System.IOUtils, System.Classes; procedure DownloadChangedFiles(const AManifest: TJSONObject; const ABaseUrl, ALocalDir: string); var LFiles: TJSONArray; LItem: TJSONObject; LPath, LUrl, LTarget, LHash: string; LStream: TFileStream; LHttp: TNetHTTPClient; i: Integer; begin LFiles : AManifest.GetValue(files) as TJSONArray; LHttp : TNetHTTPClient.Create(nil); try LHttp.UserAgent : AutoUpdater/1.0; for i : 0 to LFiles.Count - 1 do begin LItem : LFiles.Items[i] as TJSONObject; LPath : LItem.GetValuestring(path); LHash : LItem.GetValuestring(sha256); LTarget : TPath.Combine(ALocalDir, LPath); if TFile.Exists(LTarget) and SameText(CalcSHA256(LTarget), LHash) then Continue; ForceDirectories(TPath.GetDirectoryName(LTarget)); LStream : TFileStream.Create(LTarget, fmCreate or fmShareDenyWrite); try LUrl : ABaseUrl / LPath; LHttp.Get(LUrl, LStream); finally LStream.Free; end; end; finally LHttp.Free; end; end;这段代码的关键点在两个地方。Continue分支让“已经是最新”的文件跳过下载这也是差量更新的核心如果服务端清单里文件哈希算错了会把错误文件误判为已更新所以发布工具要保证哈希和服务端一致。fmCreate or fmShareDenyWrite以独占写方式创建目标文件避免下载过程中被其他进程干扰。CalcSHA256函数在第 5 章给出完整实现代码里先直接调用编译时确保引用了对应单元即可。实际项目中临时下载目录我习惯固定为ALocalDir \_updater\download\。每次升级先清空这个目录再下载新文件最后在替换阶段使用。它的好处是主程序目录保持“旧版本完整可用”即使下载到一半失败下次启动依然可以正常工作不会出现半新半旧的损坏状态。3.4 服务端与客户端的目录布局约定一个被多个现场验证过的布局是这样约定的位置内容服务器/release/manifest.json 各版本文件客户端主目录app.exe、config、资源文件客户端_updater/download/本次下载的新文件客户端_updater/backup/被替换前的旧文件客户端_updater/updater.exe独立的替换执行体服务器只需要一个静态目录manifest.json和文件放在同一路径层级下客户端用相对路径拼 URL所以不存在 CDN 重新映射目录的问题。客户端这边所有升级动作的临时产物都集中在_updater下最后交付给用户的目录里不会残留垃圾。4. 自动升级源码里最绕不过去的文件替换updater.exe 与回滚4.1 为什么正在运行的 exe 换不掉Windows 对正在运行的 exe 有一个独占打开锁DeleteFile、RenameFile、ReplaceFile都会返回“文件正在被另一进程使用”。这就形成了一个死结下载好的新 exe 在临时目录里躺好了可主程序一旦运行自己就无法覆盖自己。解决方案是引入一个独立的updater.exe。主程序检测到需要替换时启动 updater 并把自己的进程 ID 传给对方然后立刻退出updater 等主进程结束、拿到文件句柄后执行备份、替换、重启主程序。updater 必须放在临时目录不能放在被替换的主目录里否则它自己也逃不掉被占用的命运。4.2 updater 的最小可运行实现updater 的职责很简单等目标进程退出备份旧 exe把新 exe 换上去重新拉起主程序。下面这个函数是核心流程参数通过命令行传入。procedure ReplaceAndRestart; const CTarget 2; CSource 1; CBackup 3; CRestartApp 4; CWaitPid 5; var LSource, LTarget, LBackup, LApp, LWaitPid: string; LProcHandle: THandle; LStartInfo: TStartupInfo; LProcInfo: TProcessInformation; begin LSource : ParamStr(CSource); LTarget : ParamStr(CTarget); LBackup : ParamStr(CBackup); LApp : ParamStr(CRestartApp); LWaitPid : ParamStr(CWaitPid); LProcHandle : OpenProcess(SYNCHRONIZE, False, StrToIntDef(LWaitPid, 0)); if LProcHandle 0 then begin WaitForSingleObject(LProcHandle, 30000); CloseHandle(LProcHandle); end; ForceDirectories(ExtractFilePath(LBackup)); CopyFile(PChar(LTarget), PChar(LBackup), False); if not ReplaceFile(PChar(LTarget), PChar(LSource), nil, REPLACEFILE_WRITE_THROUGH, nil, nil) then MoveFileEx(PChar(LSource), PChar(LTarget), MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH); FillChar(LStartInfo, SizeOf(LStartInfo), 0); LStartInfo.cb : SizeOf(LStartInfo); if CreateProcess(nil, PChar(Format(%s /silent, [LApp])), nil, nil, False, 0, nil, nil, LStartInfo, LProcInfo) then begin CloseHandle(LProcInfo.hThread); CloseHandle(LProcInfo.hProcess); end; end;OpenProcess用SYNCHRONIZE权限打开主进程句柄配合WaitForSingleObject实现“等它退出”。超时设 30 秒正常情况下主程序会立刻退出如果用户机器上杀毒软件拦截了进程退出超时后 updater 选择放弃替换等待下一次启动重试。CopyFile先备份旧版本为回滚留后路。ReplaceFile是官方推荐的替换原语失败后降级到MoveFileEx两者都要求目标文件未被独占占用这正是前面等待主进程退出的原因。注意主程序调用 updater 时必须把命令行参数拼完整例如_updater\updater.exe 下载目录\app.exe 主目录\app.exe 备份目录\app.exe.bak 主目录\app.exe 12345最后一个参数是自身进程 ID。4.3 回滚策略启动计数与备份还原替换动作本身也可能失败或者新版本 exe 本身有问题启动即崩溃。回滚不能等用户报障而应在主程序启动阶段主动检测。常见做法是用一个本地计数文件记录“连续启动失败次数”。主程序启动时读取计数如果大于阈值就用备份目录里的旧 exe 覆盖回去正常启动后清空计数每次正常退出再把计数归零。简单实现如下procedure CheckAndRollback; var LCountFile: string; LCount: Integer; LBackup, LTarget: string; begin LCountFile : TPath.Combine(TAppDataDir, start_count.txt); LCount : 0; if TFile.Exists(LCountFile) then LCount : StrToIntDef(TFile.ReadAllText(LCountFile).Trim, 0); LBackup : TPath.Combine(TUpdateTempDir, backup\app.exe.bak); LTarget : TPath.Combine(TAppDir, app.exe); if (LCount 2) and TFile.Exists(LBackup) then begin TFile.Copy(LBackup, LTarget, True); TFile.Delete(LBackup); end; TFile.WriteAllText(LCountFile, IntToStr(LCount 1)); end;计数文件写入要在主窗口创建完成后执行不要放在initialization段里否则窗体句柄还没建立回滚动作反而把好端端的旧版本误删了。回滚后 updater 的备份文件就删掉避免每次启动都重复回滚到同一个旧版本。这里阈值定为 2 次第一次失败可能是瞬时环境问题第二次失败才认为新版不可用。5. 最后再补三个细节哈希校验、Range 续传与最小演示5.1 下载文件的 SHA256 校验函数前面代码里调用了CalcSHA256这里给出完整实现。Delphi 没有内置哈希计算器Community Edition 里用 Indy 自带的TIdHashSHA256最省事。uses IdHashSHA, IdGlobal, System.Classes; function CalcSHA256(const AFileName: string): string; var LHash: TIdHashSHA256; LStream: TFileStream; begin LHash : TIdHashSHA256.Create; try LStream : TFileStream.Create(AFileName, fmOpenRead or fmShareDenyWrite); try Result : LHash.HashBytesAsHex(LStream).ToLower; finally LStream.Free; end; finally LHash.Free; end; end;HashBytesAsHex返回大写十六进制字符串而清单里的sha256字段约定为小写所以统一做ToLower。比较时用SameText大小写不敏感双保险。哈希校验放在下载完成后、文件进入正式替换前比对不一致则删除重下。5.2 用 Range 头实现断点续传下载大资源文件时网络闪断会让整个下载重来。TNetHTTPClient支持自定义请求头发送Range即可从指定偏移续传。uses System.Net.URLClient; procedure DownloadWithResume(const AUrl, ADest: string); var LHttp: TNetHTTPClient; LStream: TFileStream; LHeaders: TArrayTNameValuePair; begin LHttp : TNetHTTPClient.Create(nil); try LStream : TFileStream.Create(ADest, fmOpenWrite or fmShareDenyWrite); try LStream.Seek(0, soEnd); LHeaders : TArrayTNameValuePair.Create( TNameValuePair.Create(Range, bytes IntToStr(LStream.Size) -) ); LHttp.Get(AUrl, LStream, LHeaders); finally LStream.Free; end; finally LHttp.Free; end; end;fmOpenWrite要求目标文件已存在调用前需要判断文件是否存在并先创建。服务器支持 Range 时返回206 Partial Content返回200则说明服务器忽略了这个头整个文件会从头写入这时需要先清空旧文件再接受完整内容。把 Range 续传和哈希校验结合起来下载大资源包的效率会明显提升。5.3 三个文件就能跑通的最小演示想验证整套方案不需要搭后台本地起一个静态文件服务即可。目录结构如下server/ manifest.json app.exe # 新版程序 client/ app.exe # 旧版程序 _updater/ download/ # 脚本运行时自动创建 backup/manifest.json里写version: 1.2.3本地 exe 的配置文件里写1.0.0手动执行FetchManifest、DownloadChangedFiles观察下载目录里出现新 exe再把ReplaceAndRestart封装成一个按钮或命令行工具触发能看到旧 exe 被替换且程序重新启动。调试时不要用Application.MessageBox阻塞 updater 的流程任何弹窗都会让等待进程退出的逻辑卡住。把 updater 用的 TLS 动态库如libssl*.dll一并放进_updater目录能省去老机器上大半 HTTPS 下载失败的排查电话。本文还有配套的精品资源点击获取