採用 Client-Server 架構:客戶端定期或啟動時呼叫 Web Service 檢查版本,若有新版本則下載檔案至暫存目錄,驗證無誤後覆蓋本地檔案並重啟應用程式。核心元件:
GetVersionInfo()、DownloadFile() 端點。<?xml version="1.0" encoding="utf-8"?>
<Modules>
<Module>
<Name>MyApp.exe</Name>
<Version>2.1.0.4</Version>
<Url_Intranet>https://intranet/update/MyApp.exe</Url_Intranet>
<Url_Internet>https://cdn.example.com/update/MyApp.exe</Url_Internet>
<Hash_SHA256>A3F2...</Hash_SHA256>
<IsCritical>true</IsCritical>
</Module>
<Module>
<Name>CoreLib.dll</Name>
<Version>1.5.2</Version>
<Url_Intranet>https://intranet/update/CoreLib.dll</Url_Intranet>
<Url_Internet>https://cdn.example.com/update/CoreLib.dll</Url_Internet>
<Hash_SHA256>B7E9...</Hash_SHA256>
</Module>
</Modules>
public class ModuleInfo
{
public string Name { get; set; }
public Version Version { get; set; }
public string UrlIntranet { get; set; }
public string UrlInternet { get; set; }
public string HashSha256 { get; set; }
public bool IsCritical { get; set; }
}
public class LocalConfig
{
public Dictionary<string, Version> InstalledVersions { get; set; } = new();
}
public class AutoUpdater
{
private readonly string _appPath;
private readonly string _tempPath;
private readonly IUpdateService _service; // Web Service Proxy
private readonly bool _useIntranet;
public AutoUpdater(string appPath, IUpdateService service, bool useIntranet = true)
{
_appPath = Path.GetFullPath(appPath);
_tempPath = Path.Combine(_appPath, "temp_update");
_service = service;
_useIntranet = useIntranet;
}
public async Task<UpdateResult> CheckAndUpdateAsync()
{
// 1. 取得伺服器版本資訊
var serverModules = await _service.GetVersionInfoAsync();
if (serverModules == null || serverModules.Count == 0)
return UpdateResult.NoUpdate;
// 2. 讀取本地版本紀錄
var localConfig = LoadLocalConfig();
// 3. 比對版本,建立需更新清單
var toUpdate = new List<ModuleInfo>();
foreach (var srv in serverModules)
{
if (!localConfig.InstalledVersions.TryGetValue(srv.Name, out var localVer) ||
localVer < srv.Version)
{
toUpdate.Add(srv);
}
}
if (toUpdate.Count == 0) return UpdateResult.NoUpdate;
// 4. 使用者確認 (UI 層處理)
// if (!UserConfirm(toUpdate)) return UpdateResult.UserCancelled;
// 5. 下載檔案至暫存目錄
Directory.CreateDirectory(_tempPath);
foreach (var mod in toUpdate)
{
string url = _useIntranet ? mod.UrlIntranet : mod.UrlInternet;
string dest = Path.Combine(_tempPath, mod.Name);
await DownloadWithRetryAsync(url, dest, 3);
// 6. 驗證雜湊值
if (!string.IsNullOrEmpty(mod.HashSha256) &&
!VerifyHash(dest, mod.HashSha256))
throw new InvalidOperationException($"檔案雜湊驗證失敗: {mod.Name}");
}
// 7. 備份並覆蓋 (需關閉主程式,通常由啟動器處理)
BackupAndReplace(toUpdate);
// 8. 更新本地版本紀錄
foreach (var mod in toUpdate)
localConfig.InstalledVersions[mod.Name] = mod.Version;
SaveLocalConfig(localConfig);
return UpdateResult.Success;
}
private async Task DownloadWithRetryAsync(string url, string dest, int maxRetry)
{
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
for (int i = 0; i < maxRetry; i++)
{
try
{
using var stream = await client.GetStreamAsync(url);
using var fs = File.Create(dest);
await stream.CopyToAsync(fs);
return;
}
catch (Exception ex) when (i < maxRetry - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); // 指數退避
}
}
throw new Exception($"下載失敗超過重試次數: {url}");
}
private bool VerifyHash(string filePath, string expectedHash)
{
using var sha = SHA256.Create();
using var fs = File.OpenRead(filePath);
var hash = Convert.ToHexString(sha.ComputeHash(fs));
return string.Equals(hash, expectedHash, StringComparison.OrdinalIgnoreCase);
}
private void BackupAndReplace(List<ModuleInfo> modules)
{
string backupDir = Path.Combine(_appPath, $"backup_{DateTime.Now:yyyyMMdd_HHmmss}");
Directory.CreateDirectory(backupDir);
foreach (var mod in modules)
{
string src = Path.Combine(_tempPath, mod.Name);
string dst = Path.Combine(_appPath, mod.Name);
if (File.Exists(dst))
File.Move(dst, Path.Combine(backupDir, mod.Name), true); // 覆蓋備份
File.Move(src, dst, true);
}
// 刪除暫存目錄
Directory.Delete(_tempPath, true);
}
private LocalConfig LoadLocalConfig()
{
string configFile = Path.Combine(_appPath, "UpdateConfig.xml");
if (!File.Exists(configFile)) return new LocalConfig();
try { return XmlSerializer.DeserializeFromFile<LocalConfig>(configFile); }
catch { return new LocalConfig(); }
}
private void SaveLocalConfig(LocalConfig config)
{
XmlSerializer.SerializeToFile(config, Path.Combine(_appPath, "UpdateConfig.xml"));
}
}
public enum UpdateResult { NoUpdate, Success, UserCancelled, Error }
主程式執行中無法覆蓋自身 EXE/DLL,需採用 獨立啟動器:
Updater.exe:唯一常駐程式,啟動時檢查更新、執行覆蓋、啟動 MyApp.exe。MyApp.exe:實際業務邏輯,不包含更新邏輯。Updater.exe 下載新版 MyApp.exe 覆蓋,再重新啟動子進程。// Updater.exe 邏輯簡化
static async Task Main()
{
var updater = new AutoUpdater(AppDomain.CurrentDomain.BaseDirectory, new UpdateServiceClient());
var result = await updater.CheckAndUpdateAsync();
if (result == UpdateResult.Success || result == UpdateResult.NoUpdate)
{
// 啟動主程式
Process.Start(new ProcessStartInfo("MyApp.exe") { UseShellExecute = true });
}
// 錯誤時顯示訊息並結束
}
[ApiController]
[Route("api/update")]
public class UpdateController : ControllerBase
{
[HttpGet("version")]
public ActionResult<List<ModuleInfo>> GetVersionInfo()
{
// 從資料庫/設定檔讀取最新版本資訊
return Ok(_updateRepository.GetAllModules());
}
[HttpGet("download/{fileName}")]
public IActionResult DownloadFile(string fileName)
{
var path = _fileStorage.GetFilePath(fileName);
if (!System.IO.File.Exists(path)) return NotFound();
return PhysicalFile(path, "application/octet-stream", fileName);
}
}
| 風險 | 對策 |
|---|---|
| 中間人攻擊竄改檔案 | HTTPS 強制傳輸 + SHA256 雜湊驗證 + 程式碼簽章驗證 |
| 惡意版本資訊誘導下載 | 版本 XML 數位簽章、伺服器憑證釘選 |
| 更新過程中斷導致損毀 | 啟動器模式 + 完整備份 + 原子性移動 (MoveFileEx) |
| 舊版本相容性問題 | 最低支援版本檢查、資料庫遷移腳本版本化 |
| 頻寬佔用影響業務 | 節流下載、背景智慧傳輸服務 (BITS)、差分更新 |
僅下載二進位差異 (如 bsdiff/Courgette 算法),大幅縮減下載量。需伺服器端預先產生 Patch 檔,客戶端套用 Patch 重組新檔案。