首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> C語言 >> 關於C語言

C# 實作應用程式線上自動更新機制完整架構設計

     編輯:關於C語言

前言

桌面應用程式部署後,如何讓使用者無感取得最新版本是關鍵課題。本文設計一套基於 Web Service 的自動更新架構:版本比對 → 差異下載 → 備份舊版 → 原子替換 → 重啟應用,並處理網路異常、權限不足、檔案鎖定等實務問題。

整體架構圖

┌─────────────┐     HTTP/HTTPS      ┌──────────────┐
│  Client App │ ◄─────────────────► │ Update Server │
│             │  1. Get Version XML │  (Web API)   │
│  - Version  │  2. Download Files  │              │
│  - Config   │  3. Apply Update    │  - Version.xml│
└─────────────┘                     │  - Files.zip  │
       │                            └──────────────┘
       ▼
┌─────────────────────┐
│  Local Update Logic │
│  - Temp Folder      │
│  - Backup & Replace │
│  - Restart Self     │
└─────────────────────┘

核心元件實作

1. 版本資訊資料模型

public class ModuleVersion
{
    public string Name { get; set; }          // 模組名稱
    public string Version { get; set; }       // 版本號(如 1.2.3.4)
    public string InternetUrl { get; set; }   // 外網下載 URL
    public string IntranetUrl { get; set; }   // 內網下載 URL
    public string Hash { get; set; }          // SHA256 校驗碼
}

2. 伺服器端 Version.xml 範例

<?xml version="1.0" encoding="utf-8"?>
<Modules>
  <Module>
    <Name>CoreLib</Name>
    <Version>2.1.0.0</Version>
    <InternetUrl>https://cdn.example.com/update/CoreLib_2.1.0.0.zip</InternetUrl>
    <IntranetUrl>https://192.168.1.100/update/CoreLib_2.1.0.0.zip</IntranetUrl>
    <Hash>A3F2...</Hash>
  </Module>
  <Module>
    <Name>MainApp</Name>
    <Version>3.0.5.1</Version>
    <InternetUrl>https://cdn.example.com/update/MainApp_3.0.5.1.zip</InternetUrl>
    <IntranetUrl>https://192.168.1.100/update/MainApp_3.0.5.1.zip</IntranetUrl>
    <Hash>F7B9...</Hash>
  </Module>
</Modules>

3. 客戶端更新核心類別

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using System.Xml.Linq;

public class AutoUpdater
{
    private readonly HttpClient _httpClient;
    private readonly string _appDirectory;
    private readonly string _tempDirectory;
    private readonly string _configPath;
    private readonly bool _useInternet;

    public AutoUpdater(string appDirectory, bool useInternet = true)
    {
        _appDirectory = appDirectory;
        _useInternet = useInternet;
        _tempDirectory = Path.Combine(_appDirectory, "temp_update");
        _configPath = Path.Combine(_appDirectory, "UpdateConfig.json");
        _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
    }

    public async Task CheckAndUpdateAsync(IProgress progress = null)
    {
        progress?.Report("連線更新伺服器...");
        var serverModules = await FetchServerVersionAsync();
        if (serverModules == null) return UpdateResult.Fail("無法取得伺服器版本資訊");

        progress?.Report("比對本地版本...");
        var localModules = LoadLocalConfig();
        var updates = CompareVersions(localModules, serverModules);
        
        if (!updates.Any()) return UpdateResult.Success("已是最新版本");

        progress?.Report($"發現 {updates.Count} 個模組需更新,開始下載...");
        foreach (var mod in updates)
        {
            string url = _useInternet ? mod.InternetUrl : mod.IntranetUrl;
            string zipPath = Path.Combine(_tempDirectory, $"{mod.Name}.zip");
            
            await DownloadWithProgressAsync(url, zipPath, p => 
                progress?.Report($"下載 {mod.Name}:{p:P0}"));
            
            if (!VerifyHash(zipPath, mod.Hash))
                return UpdateResult.Fail($"{mod.Name} 檔案校驗失敗,可能遭竄改");
        }

        progress?.Report("備份舊版本並套用更新...");
        ApplyUpdates(updates);
        
        progress?.Report("更新完成,準備重啟...");
        SaveConfig(serverModules);
        RequestRestart();
        
        return UpdateResult.Success("更新成功");
    }

    // --- 私有輔助方法 ---
    private async Task> FetchServerVersionAsync()
    {
        try
        {
            string xml = await _httpClient.GetStringAsync("https://api.example.com/version.xml");
            var doc = XDocument.Parse(xml);
            return doc.Descendants("Module").Select(m => new ModuleVersion
            {
                Name = m.Element("Name")?.Value,
                Version = m.Element("Version")?.Value,
                InternetUrl = m.Element("InternetUrl")?.Value,
                IntranetUrl = m.Element("IntranetUrl")?.Value,
                Hash = m.Element("Hash")?.Value
            }).ToList();
        }
        catch { return null; }
    }

    private Dictionary LoadLocalConfig()
    {
        if (!File.Exists(_configPath)) return new Dictionary();
        try
        {
            string json = File.ReadAllText(_configPath);
            return JsonSerializer.Deserialize>(json) 
                   ?? new Dictionary();
        }
        catch { return new Dictionary(); }
    }

    private List CompareVersions(Dictionary local, List server)
    {
        var updates = new List();
        foreach (var srv in server)
        {
            if (!local.TryGetValue(srv.Name, out var loc) || 
                Version.Parse(loc.Version) < Version.Parse(srv.Version))
            {
                updates.Add(srv);
            }
        }
        return updates;
    }

    private async Task DownloadWithProgressAsync(string url, string destPath, Action progress)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(destPath));
        using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        
        long? total = response.Content.Headers.ContentLength;
        using var stream = await response.Content.ReadAsStreamAsync();
        using var fs = File.Create(destPath);
        
        byte[] buffer = new byte[8192];
        long downloaded = 0;
        int read;
        while ((read = await stream.ReadAsync(buffer)) > 0)
        {
            await fs.WriteAsync(buffer.AsMemory(0, read));
            downloaded += read;
            if (total.HasValue) progress?.Invoke((double)downloaded / total.Value);
        }
    }

    private bool VerifyHash(string filePath, string expectedHash)
    {
        using var sha256 = SHA256.Create();
        using var fs = File.OpenRead(filePath);
        byte[] hash = sha256.ComputeHash(fs);
        string actual = Convert.ToHexString(hash);
        return string.Equals(actual, expectedHash, StringComparison.OrdinalIgnoreCase);
    }

    private void ApplyUpdates(List updates)
    {
        Directory.CreateDirectory(_tempDirectory);
        foreach (var mod in updates)
        {
            string zipPath = Path.Combine(_tempDirectory, $"{mod.Name}.zip");
            string extractPath = Path.Combine(_tempDirectory, mod.Name);
            
            // 解壓縮
            ZipFile.ExtractToDirectory(zipPath, extractPath, true);
            
            // 備份並替換
            foreach (string file in Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories))
            {
                string relPath = file.Substring(extractPath.Length + 1);
                string destFile = Path.Combine(_appDirectory, relPath);
                
                Directory.CreateDirectory(Path.GetDirectoryName(destFile));
                if (File.Exists(destFile))
                    File.Move(destFile, destFile + $".bak_{DateTime.Now:yyyyMMddHHmmss}", true);
                
                File.Move(file, destFile);
            }
        }
    }

    private void SaveConfig(List modules)
    {
        var dict = modules.ToDictionary(m => m.Name, m => m);
        string json = JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true });
        File.WriteAllText(_configPath, json);
    }

    private void RequestRestart()
    {
        string exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
        System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
        {
            FileName = exePath,
            UseShellExecute = true
        });
        Environment.Exit(0);
    }
}

public record UpdateResult(bool Success, string Message)
{
    public static UpdateResult Success(string msg) => new(true, msg);
    public static UpdateResult Fail(string msg) => new(false, msg);
}

關鍵設計決策解析

1. 版本比對策略

  • 使用 Version.Parse 進行語意版本比較,正確處理 1.10 > 1.2 等情況。
  • 支援多模組獨立版本控制(核心庫、主程式、外掛等)。

2. 安全性考量

  • HTTPS 強制:生產環境僅允許 HTTPS 下載,防止中間人攻擊植入惡意碼。
  • SHA256 校驗:伺服器端產生雜湊值寫入 Version.xml,客戶端下載後必驗證,確保檔案完整性。
  • 簽章驗證(進階):建議對更新套件進行 Authenticode 簽章,客戶端驗證憑證鏈。

3. 原子性替換與備份

  • 下載至暫存目錄 temp_update,驗證通過後一次性移動替換。
  • 舊檔案改名備份(附時間戳),更新失敗可手動回滾。
  • 若檔案被鎖定(程式正在執行),需先關閉主程式或使用「重啟替換」技術(MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT)。

4. 重啟策略

  • 簡單模式:啟動新進程 → 結束當前進程(適用單一 EXE)。
  • 進階模式:啟動獨立 Updater.exe,由它負責關閉主程式、替換檔案、重啟主程式(支援多檔案、服務更新)。

常見問題與解決方案

Q: 使用者無管理員權限,Program Files 寫入失敗?
A: 將應用程式安裝至 %LocalAppData%\YourApp(使用者目錄),或採用 ClickOnce / MSIX 部署自動處理權限。
Q: 防火牆/Proxy 封鎖下載?
A: 支援系統 Proxy 設定(HttpClientHandler.UseProxy = true),提供內網鏡像站選項(IntranetUrl)。
Q: 更新過程中斷電導致檔案損毀?
A: 採用「影子複製」機制:更新前完整複製整個應用目錄至 backup_日期,啟動時檢測上次更新是否完成,未完成自動還原備份。
Q: 如何支援增量更新(只下載差異)?
A: 伺服器端產生二進位差異包(如 bsdiff),客戶端下載差異包並套用(bspatch),大幅減少頻寬。成熟方案參考 Squirrel.Windows、NetSparkle。

成熟開源框架推薦(勿重複造輪子)

  • Squirrel.Windows:GitHub 桌面客戶端同款,支援增量更新、Delta 壓縮、自動重啟、NuGet 為套件來源。
  • NetSparkle:Sparkle (macOS) 移植版,支援 AppCast (RSS)、DSA 簽章驗證、WPF/WinForms 整合。
  • AutoUpdater.NET:輕量級,支援 GitHub Releases、自定義 XML/JSON、強制/可選更新模式。

總結

自動更新系統涉及網路傳輸、檔案系統操作、安全驗證、程序生命週期管理等多個面向。本文提供的核心架構涵蓋版本比對、安全下載、校驗、原子替換與重啟的完整流程。實務專案中,強烈建議採用成熟開源框架(Squirrel/NetSparkle),專注於業務邏輯而非底層更新機制的維護。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved