.NET客户端自动更新的设计与实现

本文从设计到实践,实现了.NET客服端自动检查更新的过程,其中应用到了中间件Nginx。文中的示例在实际的场景 Linux服务器---Linux客户端中 经过验证完全可行。

1. 框架设计

1.1 设计图

1.2 服务器配置文件

cs 复制代码
{
  "version": "1.0.0.2025111705",
  "downloadUrl": "http://你的服务器IP:82/你的压缩包的名称.zip",
  "sha256": "a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcd",
  "releaseNotes": "版本更新测试",
  "isCritical": false,
  "releaseDate": "2025-11-14T10:00:00Z",
  "minOsVersion": "ubuntu18.04",
  "fileSize": 209808384
}

2. 代码实现设计

2.1 新建版本信息模型及更新操作的结果模型

cs 复制代码
 /// <summary>
 /// 版本信息模型,用于描述服务器上的版本信息和更新详情
 /// 这个类会从服务器的version.json文件反序列化得到
 /// </summary>
 public class VersionInfo
 {
     /// <summary>
     /// 版本号,格式为"主版本.次版本.修订号",例如:"1.2.3"
     /// 客户端会使用这个字段与本地版本进行比较
     /// </summary>
     [JsonPropertyName("version")]
     public string Version { get; set; } = "1.0.0";

     /// <summary>
     /// 新版本程序的下载地址,可以是完整的HTTP/HTTPS URL
     /// 客户端会从这个URL下载更新文件
     /// </summary>
     [JsonPropertyName("downloadUrl")]
     public string DownloadUrl { get; set; } = "";

     /// <summary>
     /// 新版本程序的SHA256哈希值,用于验证下载文件的完整性
     /// 防止文件在下载过程中被篡改或损坏
     /// </summary>
     [JsonPropertyName("sha256")]
     public string Sha256Hash { get; set; } = "";

     /// <summary>
     /// 版本发布说明,描述新版本的变更内容、修复的问题等
     /// 可以在UI中显示给用户看
     /// </summary>
     [JsonPropertyName("releaseNotes")]
     public string ReleaseNotes { get; set; } = "";

     /// <summary>
     /// 是否为关键更新,如果是true,客户端应该强制用户更新
     /// 非关键更新用户可以跳过
     /// </summary>
     [JsonPropertyName("isCritical")]
     public bool IsCritical { get; set; }

     /// <summary>
     /// 版本发布日期,用于在UI中显示更新时间
     /// </summary>
     [JsonPropertyName("releaseDate")]
     public DateTime ReleaseDate { get; set; }

     /// <summary>
     /// 最低要求的操作系统版本,用于兼容性检查
     /// 例如:"ubuntu18.04" 或 "windows10"
     /// </summary>
     [JsonPropertyName("minOsVersion")]
     public string MinOsVersion { get; set; } = "";

     /// <summary>
     /// 更新文件的大小(字节数),用于显示下载进度和预估时间
     /// </summary>
     [JsonPropertyName("fileSize")]
     public long FileSize { get; set; }
 }

 // Models/UpdateResult.cs
 /// <summary>
 /// 更新操作的结果模型,包含更新操作的成功状态和详细信息
 /// 每个更新相关的方法都会返回这个类型的结果
 /// </summary>
 public class UpdateResult
 {
     /// <summary>
     /// 操作是否成功完成
     /// true表示成功,false表示失败
     /// </summary>
     public bool Success { get; set; }

     /// <summary>
     /// 操作的详细描述信息,可以是成功信息或错误信息
     /// 用于在UI中显示给用户或在日志中记录
     /// </summary>
     public string Message { get; set; } = "";

     /// <summary>
     /// 如果发现有新版本,这个字段包含新版本的详细信息
     /// 如果没有新版本或检查失败,这个字段为null
     /// </summary>
     public VersionInfo? NewVersion { get; set; }

     /// <summary>
     /// 是否需要重启应用程序来完成更新
     /// 如果为true,应用程序应该在合适的时机重启
     /// </summary>
     public bool RequiresRestart { get; set; }

     /// <summary>
     /// 如果操作过程中发生异常,这个字段包含异常信息
     /// 用于调试和错误分析
     /// </summary>
     public Exception? Error { get; set; }
 }

 // Models/DownloadProgress.cs
 /// <summary>
 /// 下载进度模型,用于实时报告文件下载的进度信息
 /// 通过IProgress接口传递给UI层进行进度显示
 /// </summary>
 public class DownloadProgress
 {
     /// <summary>
     /// 文件的总大小,单位是字节
     /// 如果服务器没有提供文件大小,这个值可能是-1
     /// </summary>
     public long TotalBytes { get; set; }

     /// <summary>
     /// 已经下载的字节数
     /// 这个值会从0逐渐增加到TotalBytes
     /// </summary>
     public long BytesDownloaded { get; set; }

     /// <summary>
     /// 下载进度百分比,范围是0到100
     /// 计算公式:BytesDownloaded / TotalBytes * 100
     /// </summary>
     public double ProgressPercentage { get; set; }

     /// <summary>
     /// 预估的剩余下载时间
     /// 根据当前的下载速度计算得出
     /// </summary>
     public TimeSpan EstimatedTimeRemaining { get; set; }

     /// <summary>
     /// 当前的下载速度,单位是KB/秒
     /// 用于在UI中显示实时网速
     /// </summary>
     public double DownloadSpeed { get; set; }
 }

2.2 文件下载器类

cs 复制代码
 /// <summary>
 /// 文件下载器类,负责从网络下载文件并支持进度报告和重试机制
 /// 这个类实现了IFileDownloader接口,支持依赖注入
 /// </summary>
 public class FileDownloader : IFileDownloader
 {
     /// <summary>
     /// HTTP客户端实例,用于执行网络请求
     /// 使用静态实例可以复用TCP连接,提高性能
     /// </summary>
     private readonly HttpClient _httpClient;

     /// <summary>
     /// 临时文件存储目录,用于存放下载中的文件
     /// 下载完成后,文件会被移动到最终位置
     /// </summary>
     private readonly string _tempDirectory;

     /// <summary>
     /// 默认构造函数,创建新的文件下载器实例
     /// 会初始化HTTP客户端和临时目录
     /// </summary>
     public FileDownloader()
     {
         var handler = new HttpClientHandler();

         // 配置HttpClient,设置适当的超时时间和默认请求头
         _httpClient = new HttpClient(handler)
         {
             Timeout = TimeSpan.FromMinutes(10)  // 设置10分钟超时,适合大文件下载
         };

         // 设置User-Agent,让服务器识别我们的应用程序
         _httpClient.DefaultRequestHeaders.Add("User-Agent", "AvaloniaApp-Updater/1.0");

         // 创建应用程序专用的临时目录
         _tempDirectory = Path.Combine(Path.GetTempPath(), "AvaloniaApp", "downloads");
         CrossPlatformService.EnsureDirectoryExists(_tempDirectory);
     }

     /// <summary>
     /// 从指定URL下载文件到临时位置
     /// </summary>
     /// <param name="url">要下载的文件URL</param>
     /// <param name="progress">进度报告器,用于实时报告下载进度</param>
     /// <param name="cancellationToken">取消令牌,用于取消下载操作</param>
     /// <returns>下载文件的临时路径</returns>
     /// <exception cref="ArgumentException">当URL为空或无效时抛出</exception>
     /// <exception cref="Exception">当下载过程中发生错误时抛出</exception>
     public async Task<string> DownloadFileAsync(string url, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default)
     {
         // 参数验证
         if (string.IsNullOrEmpty(url))
             throw new ArgumentException("下载URL不能为空");

         // 从URL中提取文件名,如果无法提取则使用GUID生成唯一文件名
         var fileName = GetFileNameFromUrl(url) ?? $"update_{Guid.NewGuid():N}";
         var tempFilePath = Path.Combine(_tempDirectory, fileName);

         try
         {
             // 发送HTTP GET请求,使用流模式以便实时处理数据
             using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);

             // 确保HTTP响应状态码表示成功
             response.EnsureSuccessStatusCode();

             // 获取文件总大小,用于进度计算
             var totalBytes = response.Content.Headers.ContentLength ?? -1L;
             var canReportProgress = totalBytes != -1 && progress != null;

             // 获取响应流和文件流
             using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
             using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None);

             var buffer = new byte[8192];  // 8KB缓冲区
             var totalBytesRead = 0L;      // 已读取的总字节数
             int bytesRead;                // 每次读取的字节数
             var stopwatch = System.Diagnostics.Stopwatch.StartNew();  // 用于计算下载速度

             // 循环读取数据直到流结束
             while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) != 0)
             {
                 // 检查是否请求取消操作
                 cancellationToken.ThrowIfCancellationRequested();

                 // 将数据写入文件
                 await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
                 totalBytesRead += bytesRead;

                 // 如果支持进度报告,计算并报告当前进度
                 if (canReportProgress)
                 {
                     var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
                     var downloadSpeed = elapsedSeconds > 0 ? totalBytesRead / elapsedSeconds / 1024 : 0; // 计算下载速度(KB/s)
                     var remainingTime = elapsedSeconds > 0 ? TimeSpan.FromSeconds((totalBytes - totalBytesRead) / (totalBytesRead / elapsedSeconds)) : TimeSpan.Zero;

                     // 创建进度报告对象并报告进度
                     progress.Report(new DownloadProgress
                     {
                         TotalBytes = totalBytes,
                         BytesDownloaded = totalBytesRead,
                         ProgressPercentage = (double)totalBytesRead / totalBytes * 100,
                         DownloadSpeed = downloadSpeed,
                         EstimatedTimeRemaining = remainingTime
                     });
                 }
             }

             return tempFilePath;
         }
         catch (Exception ex)
         {
             // 如果下载失败,清理已下载的临时文件
             SafeDeleteFile(tempFilePath);
             throw new Exception($"文件下载失败: {ex.Message}", ex);
         }
     }

     /// <summary>
     /// 带重试机制的文件下载方法
     /// 如果下载失败,会自动重试指定次数
     /// </summary>
     /// <param name="url">要下载的文件URL</param>
     /// <param name="localPath">文件下载后的本地保存路径</param>
     /// <param name="maxRetries">最大重试次数,默认3次</param>
     /// <param name="progress">进度报告器</param>
     /// <param name="cancellationToken">取消令牌</param>
     /// <returns>是否下载成功</returns>
     public async Task<bool> DownloadFileWithRetryAsync(string url, string localPath, int maxRetries = 3, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default)
     {
         // 重试循环
         for (int attempt = 0; attempt < maxRetries; attempt++)
         {
             try
             {
                 // 尝试下载文件到临时位置
                 var tempPath = await DownloadFileAsync(url, progress, cancellationToken);

                 // 确保目标目录存在
                 var targetDir = Path.GetDirectoryName(localPath);
                 if (!string.IsNullOrEmpty(targetDir))
                 {
                     CrossPlatformService.EnsureDirectoryExists(targetDir);
                 }

                 // 将文件从临时位置移动到最终位置
                 File.Move(tempPath, localPath, true);
                 return true;
             }
             catch (Exception ex) when (attempt < maxRetries - 1)  // 如果不是最后一次尝试
             {
                 // 使用指数退避策略:等待时间随重试次数指数增长
                 // 第一次重试等待2秒,第二次4秒,第三次8秒...
                 await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
                 continue;  // 继续下一次重试
             }
         }
         return false;  // 所有重试都失败了
     }

     /// <summary>
     /// 从URL中提取文件名
     /// 例如:从 "https://example.com/files/app-v1.2.0.zip" 中提取 "app-v1.2.0.zip"
     /// </summary>
     /// <param name="url">文件URL</param>
     /// <returns>文件名,如果无法提取则返回null</returns>
     private string GetFileNameFromUrl(string url)
     {
         try
         {
             var uri = new Uri(url);
             return Path.GetFileName(uri.LocalPath);
         }
         catch
         {
             return null;  // URL格式无效,返回null
         }
     }

     /// <summary>
     /// 安全删除文件,忽略删除过程中可能出现的异常
     /// 这个方法用于清理临时文件,即使删除失败也不应该影响主要逻辑
     /// </summary>
     /// <param name="filePath">要删除的文件路径</param>
     private void SafeDeleteFile(string filePath)
     {
         try
         {
             if (File.Exists(filePath))
                 File.Delete(filePath);
         }
         catch
         {
             // 忽略所有删除异常,确保不会影响主流程
         }
     }

     /// <summary>
     /// 释放资源,主要是HttpClient和临时目录
     /// 实现IDisposable接口
     /// </summary>
     public void Dispose()
     {
         _httpClient?.Dispose();  // 释放HttpClient

         // 清理临时目录
         try
         {
             if (Directory.Exists(_tempDirectory))
             {
                 Directory.Delete(_tempDirectory, true);  // 递归删除临时目录
             }
         }
         catch
         {
             // 忽略清理过程中的异常
         }
     }
 }

 // Interfaces/IFileDownloader.cs
 /// <summary>
 /// 文件下载器接口,定义了文件下载服务的基本功能
 /// 使用接口可以方便地进行单元测试和依赖注入
 /// </summary>
 public interface IFileDownloader : IDisposable
 {
     /// <summary>
     /// 从指定URL下载文件到临时位置
     /// </summary>
     Task<string> DownloadFileAsync(string url, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default);

     /// <summary>
     /// 带重试机制的文件下载方法
     /// </summary>
     Task<bool> DownloadFileWithRetryAsync(string url, string localPath, int maxRetries = 3, IProgress<DownloadProgress> progress = null, CancellationToken cancellationToken = default);
 }

2.3 跨平台服务及备份服务

cs 复制代码
 /// <summary>
 /// 跨平台服务类,提供在不同操作系统上的兼容性支持
 /// 这个类封装了所有与平台相关的操作,让其他代码可以跨平台运行
 /// </summary>
 public static class CrossPlatformService
 {
     /// <summary>
     /// 检查当前是否运行在Linux操作系统上
     /// </summary>
     public static bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);

     /// <summary>
     /// 检查当前是否运行在Windows操作系统上
     /// </summary>
     public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

     /// <summary>
     /// 检查当前是否运行在macOS操作系统上
     /// </summary>
     public static bool IsOSX => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);

     /// <summary>
     /// 获取应用程序数据存储目录
     /// 这个目录用于存储配置文件、备份文件等持久化数据
     /// </summary>
     /// <param name="appName">应用程序名称,用于创建子目录</param>
     /// <returns>平台特定的应用程序数据目录路径</returns>
     public static string GetAppDataPath(string appName)
     {
         string basePath;

         if (IsLinux || IsOSX)
         {
             // Linux/macOS系统:使用 ~/.local/share/AppName 目录
             // 这是Linux和macOS上存储应用程序数据的标准位置
             var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
             basePath = Path.Combine(home, ".local", "share", appName);
         }
         else
         {
             // Windows系统:使用 AppData/Local/AppName 目录
             // 这是Windows上存储应用程序数据的标准位置
             basePath = Path.Combine(
                 Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                 appName);
         }

         // 确保目录存在,如果不存在则创建
         EnsureDirectoryExists(basePath);
         return basePath;
     }

     /// <summary>
     /// 获取当前正在运行的应用程序的完整路径
     /// 这个方法可以正确处理各种启动方式(直接运行、通过符号链接等)
     /// </summary>
     /// <returns>当前应用程序的可执行文件路径</returns>
     /// <exception cref="FileNotFoundException">当无法确定应用程序路径时抛出</exception>
     public static string GetApplicationPath()
     {
         // 首先尝试使用Environment.ProcessPath,这是.NET 5+推荐的方式
         var processPath = Environment.ProcessPath;
         if (!string.IsNullOrEmpty(processPath) && File.Exists(processPath))
             return processPath;

         // 在Linux系统上,可以通过/proc/self/exe获取当前进程的可执行文件路径
         if (IsLinux && File.Exists("/proc/self/exe"))
         {
             return "/proc/self/exe";
         }

         throw new FileNotFoundException("无法确定应用程序路径");
     }

     /// <summary>
     /// 获取当前应用程序所在的目录路径
     /// 这个目录通常包含应用程序的可执行文件和依赖项
     /// </summary>
     /// <returns>应用程序目录路径</returns>
     public static string GetApplicationDirectory()
     {
         return Path.GetDirectoryName(GetApplicationPath()) ?? AppContext.BaseDirectory;
     }

     /// <summary>
     /// 获取当前应用程序的可执行文件名(不包含路径)
     /// 例如:在Windows上返回"MyApp.exe",在Linux上返回"MyApp"
     /// </summary>
     /// <returns>应用程序文件名</returns>
     public static string GetApplicationName()
     {
         return Path.GetFileName(GetApplicationPath());
     }

     /// <summary>
     /// 确保指定的目录存在,如果不存在则创建它
     /// 这个方法会递归创建所有必要的父目录
     /// </summary>
     /// <param name="path">要确保存在的目录路径</param>
     public static void EnsureDirectoryExists(string path)
     {
         if (!Directory.Exists(path))
         {
             Directory.CreateDirectory(path);
         }
     }

     /// <summary>
     /// 设置文件为可执行权限(仅在Linux和macOS上有效)
     /// 在Windows上,这个方法不会执行任何操作
     /// </summary>
     /// <param name="filePath">要设置权限的文件路径</param>
     /// <returns>表示异步操作的任务</returns>
     /// <exception cref="Exception">当设置权限失败时抛出</exception>
     public static async Task SetFileExecutable(string filePath)
     {
         // 只在Linux和macOS上设置执行权限
         if (IsLinux || IsOSX)
         {
             try
             {
                 // 使用chmod命令设置文件为可执行
                 // 755权限表示:所有者可读可写可执行,组用户和其他用户可读可执行
                 using var process = new Process();
                 process.StartInfo.FileName = "chmod";
                 process.StartInfo.Arguments = $"+x \"{filePath}\"";
                 process.StartInfo.UseShellExecute = false;      // 不使用系统shell
                 process.StartInfo.CreateNoWindow = true;        // 不创建窗口
                 process.StartInfo.RedirectStandardOutput = true; // 重定向标准输出
                 process.StartInfo.RedirectStandardError = true;  // 重定向错误输出

                 process.Start();
                 await process.WaitForExitAsync();

                 // 检查命令执行是否成功
                 if (process.ExitCode != 0)
                 {
                     var error = await process.StandardError.ReadToEndAsync();
                     throw new Exception($"设置文件权限失败: {error}");
                 }
             }
             catch (Exception ex)
             {
                 throw new Exception($"设置执行权限失败: {ex.Message}");
             }
         }
     }

     /// <summary>
     /// 获取当前平台的可执行文件扩展名
     /// </summary>
     /// <returns>平台特定的文件扩展名</returns>
     public static string GetPlatformSpecificExtension()
     {
         if (IsWindows) return ".exe";    // Windows可执行文件扩展名
         if (IsLinux) return "";          // Linux可执行文件通常没有扩展名
         if (IsOSX) return ".app";        // macOS应用程序包扩展名
         return "";
     }
 }
 /// <summary>
 /// 备份管理器
 /// </summary>
 public class BackupManager
 {
     private readonly string _appName;
     private readonly string _backupDirectory;
     private const int MaxBackups = 5;

     public BackupManager(string appName)
     {
         _appName = appName;
         _backupDirectory = Path.Combine(
             CrossPlatformService.GetAppDataPath(appName),
             "backups");

         CrossPlatformService.EnsureDirectoryExists(_backupDirectory);
     }

     public async Task CreateBackupAsync()
     {
         var currentAppPath = CrossPlatformService.GetApplicationPath();
         var backupPath = GetBackupPath("previous");

         if (File.Exists(currentAppPath))
         {
             // 复制当前程序文件
             File.Copy(currentAppPath, backupPath, true);

             // 备份配置文件
             await BackupConfigurationFilesAsync();

             // 清理旧备份
             await CleanupOldBackupsAsync();
         }
     }

     public async Task<bool> RollbackAsync()
     {
         try
         {
             var backupPath = GetBackupPath("previous");
             var currentAppPath = CrossPlatformService.GetApplicationPath();

             if (File.Exists(backupPath))
             {
                 // 恢复程序文件
                 File.Copy(backupPath, currentAppPath, true);

                 // 恢复配置文件
                 await RestoreConfigurationFilesAsync();

                 // 设置执行权限(Linux/macOS)
                 await CrossPlatformService.SetFileExecutable(currentAppPath);

                 return true;
             }
             return false;
         }
         catch (Exception ex)
         {
             throw new Exception($"回退失败: {ex.Message}", ex);
         }
     }

     public string GetLatestBackupPath()
     {
         return GetBackupPath("previous");
     }

     private string GetBackupPath(string suffix)
     {
         var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
         return Path.Combine(_backupDirectory, $"{_appName}.{suffix}.{timestamp}");
     }

     private async Task BackupConfigurationFilesAsync()
     {
         var appDir = CrossPlatformService.GetApplicationDirectory();
         var configFiles = Directory.GetFiles(appDir, "*.json")
             .Concat(Directory.GetFiles(appDir, "*.config"))
             .Concat(Directory.GetFiles(appDir, "*.xml"));

         foreach (var configFile in configFiles)
         {
             var fileName = Path.GetFileName(configFile);
             var backupPath = Path.Combine(_backupDirectory, fileName);
             File.Copy(configFile, backupPath, true);
         }
         await Task.CompletedTask;
     }

     private async Task RestoreConfigurationFilesAsync()
     {
         var backupFiles = Directory.GetFiles(_backupDirectory)
             .Where(f => !f.Contains(_appName)); // 排除程序备份文件

         foreach (var backupFile in backupFiles)
         {
             var fileName = Path.GetFileName(backupFile);
             var targetPath = Path.Combine(CrossPlatformService.GetApplicationDirectory(), fileName);
             File.Copy(backupFile, targetPath, true);
         }
         await Task.CompletedTask;
     }

     private async Task CleanupOldBackupsAsync()
     {
         try
         {
             var backupFiles = Directory.GetFiles(_backupDirectory, $"{_appName}.*")
                 .Select(f => new FileInfo(f))
                 .OrderByDescending(f => f.CreationTime)
                 .ToList();

             if (backupFiles.Count > MaxBackups)
             {
                 foreach (var oldBackup in backupFiles.Skip(MaxBackups))
                 {
                     oldBackup.Delete();
                 }
             }
             await Task.CompletedTask;
         }
         catch
         {
             // 忽略清理错误
         }
     }
 }

2.4 更新管理器

cs 复制代码
 /// <summary>
 /// 更新管理器
 /// </summary>

 public class UpdateManager : IUpdateService
 {
     private readonly string _appName;
     /// <summary>
     /// 当前版本
     /// </summary>
     private readonly string _currentVersion;
     private readonly string _updateBaseUrl;
     private readonly BackupManager _backupManager;
     private readonly IFileDownloader _fileDownloader;

     public UpdateManager(string appName, string currentVersion, string updateBaseUrl, IFileDownloader fileDownloader = null)
     {
         _appName = appName;
         _currentVersion = currentVersion;
         _updateBaseUrl = updateBaseUrl;
         _fileDownloader = fileDownloader ?? new FileDownloader();
         _backupManager = new BackupManager(appName);
     }

     public async Task<UpdateResult> CheckForUpdatesAsync()
     {
         try
         {
             using var httpClient = new HttpClient();
             var versionInfoUrl = _updateBaseUrl;

             var response = await httpClient.GetStringAsync(versionInfoUrl);
             var serverVersion = JsonSerializer.Deserialize<VersionInfo>(response);

             if (serverVersion == null)
                 return new UpdateResult { Success = false, Message = "无法解析版本信息" };

             // 验证版本格式
             if (!Version.TryParse(serverVersion.Version, out var latestVersion))
                 return new UpdateResult { Success = false, Message = "服务器版本格式无效" };

             if (!Version.TryParse(_currentVersion, out var currentVersion))
                 return new UpdateResult { Success = false, Message = "当前版本格式无效" };

             if (latestVersion > currentVersion)
             {
                 return new UpdateResult
                 {
                     Success = true,
                     NewVersion = serverVersion,
                     RequiresRestart = true,
                     Message = $"发现新版本: {serverVersion.Version}"
                 };
             }

             return new UpdateResult { Success = true, Message = "已是最新版本" };
         }
         catch (HttpRequestException ex)
         {
             return new UpdateResult { Success = false, Message = $"网络错误: {ex.Message}" };
         }
         catch (Exception ex)
         {
             return new UpdateResult { Success = false, Message = $"检查更新失败: {ex.Message}", Error = ex };
         }
     }

     public async Task<UpdateResult> PerformUpdateAsync(VersionInfo versionInfo, IProgress<DownloadProgress> progress = null)
     {
         try
         {
             // 1. 备份当前版本
             await _backupManager.CreateBackupAsync();

             // 2. 下载新版本
             string tempFilePath;
             try
             {
                 tempFilePath = await _fileDownloader.DownloadFileAsync(versionInfo.DownloadUrl, progress);
             }
             catch (Exception ex)
             {
                 throw new Exception($"下载失败: {ex.Message}", ex);
             }

             // 3. 验证文件完整性
             if (!string.IsNullOrEmpty(versionInfo.Sha256Hash) && !await VerifyFileHashAsync(tempFilePath, versionInfo.Sha256Hash))
             {
                 throw new Exception("文件校验失败,文件可能已损坏或被篡改");
             }

             // 4. 准备更新
             await PrepareUpdateAsync(tempFilePath);

             return new UpdateResult
             {
                 Success = true,
                 Message = "更新准备完成,重启后生效",
                 RequiresRestart = true
             };
         }
         catch (Exception ex)
         {
             // 更新失败,尝试回退
             try
             {
                 await _backupManager.RollbackAsync();
             }
             catch (Exception rollbackEx)
             {
                 throw new AggregateException($"更新失败: {ex.Message}",
                     new Exception($"回退也失败: {rollbackEx.Message}"));
             }

             throw new Exception($"更新失败,已回退到前一版本: {ex.Message}", ex);
         }
     }

     private async Task<bool> VerifyFileHashAsync(string filePath, string expectedHash)
     {
         try
         {
             using var stream = File.OpenRead(filePath);
             using var sha256 = SHA256.Create();
             var hash = await sha256.ComputeHashAsync(stream);
             var actualHash = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();

             return actualHash == expectedHash.ToLowerInvariant();
         }
         catch
         {
             return false;
         }
     }

     private async Task PrepareUpdateAsync(string newAppPath)
     {
         var appDirectory = CrossPlatformService.GetApplicationDirectory();
         var currentAppPath = CrossPlatformService.GetApplicationPath();

         // 创建更新标记文件,包含新版本路径
         var updateInfo = new
         {
             NewAppPath = newAppPath,
             CurrentAppPath = currentAppPath,
             BackupPath = _backupManager.GetLatestBackupPath(),
             Timestamp = DateTime.UtcNow
         };

         var updateInfoPath = Path.Combine(appDirectory, "update_info.json");
         await File.WriteAllTextAsync(updateInfoPath, JsonSerializer.Serialize(updateInfo, new JsonSerializerOptions { WriteIndented = true }));

         // 创建重启标记
         var restartFlagPath = Path.Combine(appDirectory, "restart.flag");
         await File.WriteAllTextAsync(restartFlagPath, "true");
     }

     public void Dispose()
     {
         _fileDownloader?.Dispose();
     }
 }

 // Interfaces/IUpdateService.cs
 public interface IUpdateService : IDisposable
 {
     Task<UpdateResult> CheckForUpdatesAsync();
     Task<UpdateResult> PerformUpdateAsync(VersionInfo versionInfo, IProgress<DownloadProgress> progress = null);
 }

3. 实际开发过程调用

在启动程序时,检查当前版本是否为最新版本,然后选择是否更新

cs 复制代码
  private readonly UpdateManager _updateManager;
 private bool _isCheckingForUpdates;
 private string _updateStatus = "就绪";
 private double _downloadProgress;
 /// <summary>
 /// 更新状态文本,用于在UI中显示当前更新状态
 /// </summary>
 public string UpdateStatus
 {
     get => _updateStatus;
     set => SetProperty(ref _updateStatus, value);//这里继承自Prism.Mvvm的BindableBase类的方法,用于自动更新前端界面数据
 }

 /// <summary>
 /// 下载进度(0-100),用于进度条显示
 /// </summary>
 public double DownloadProgress
 {
     get => _downloadProgress;
     set => SetProperty(ref _downloadProgress, value);
 }
 private double _downloadSpeed;
 /// <summary>
 /// 下载速度(KB/s),用于显示实时下载速度
 /// </summary>
 public double DownloadSpeed
 {
     get => _downloadSpeed;
     set => SetProperty(ref _downloadSpeed, value);
 }

 /// <summary>
 /// 检查更新命令 - 用户点击"检查更新"按钮时执行
 /// </summary>
 public ReactiveCommand<System.Reactive.Unit, System.Reactive.Unit> CheckForUpdatesCommand { get; }

 public ExecuteMainVM(IEventAggregator eventAggregator)
  {
      
//假设这是你的主界面ViewModel
          // 初始化更新管理器
          // 参数说明:
          // - "YourAvaloniaApp": 应用程序名称
          // - GetCurrentVersion(): 当前版本号
          // - "https://your-server.com/updates": 更新服务器地址
          IsUpdate = true;
          _updateManager = new UpdateManager(
              "HYPMView.Desktop",
              GetCurrentVersion(),
              BaseClass.SYS.UpgradeUrl);

          // 初始化检查更新命令
          // 使用ReactiveCommand实现响应式UI
          CheckForUpdatesCommand = ReactiveCommand.CreateFromTask(
              execute: CheckForUpdatesAsync,
              canExecute: this.WhenAnyValue(x => x._isCheckingForUpdates)
                             .Select(checking => !checking)  // 正在检查时禁用按钮
          );

          //// 应用程序启动时自动检查更新(可选)
          Observable.StartAsync(CheckForUpdatesAsync);
          
          
  }
        /// <summary>
        /// 检查更新异步方法
        /// 这个方法会在后台执行,不会阻塞UI
        /// </summary>
        private async Task CheckForUpdatesAsync()
        {
            if (_isCheckingForUpdates) return;

            _isCheckingForUpdates = true;
            UpdateStatus = "正在检查更新...";

            try
            {
                // 调用更新管理器检查是否有新版本
                var result = await _updateManager.CheckForUpdatesAsync();

                if (result.Success && result.NewVersion != null)
                {
                    // 发现新版本,显示更新对话框
                    UpdateStatus = $"发现新版本: {result.NewVersion.Version}";

                    // 包含版本号、更新说明、文件大小等信息

                    var message = $"发现新版本 {result.NewVersion.Version}\n\n" +
                                 $"更新内容:\n{result.NewVersion.ReleaseNotes}\n\n" +
                                 $"文件大小: {FormatFileSize(result.NewVersion.FileSize)}\n\n" +
                                 $"是否立即下载并安装更新?";

                    MessageTipWindow messageTip = new MessageTipWindow();
                    messageTip.setVm(message, "25", "更新提醒!");
                    var resultUpdate= await  messageTip.ShowDialog<bool>(ExecuteMainFrame.CurrentWindow);//这里是自定义的消息提示框,可以换成自己的提示框,后面类似弹窗做同样的处理

                    if (resultUpdate)
                    {
                        await DownloadAndInstallUpdateAsync(result.NewVersion);
                    }
                    else
                    {
                        UpdateStatus = "用户取消更新";
                        await Task.Delay(1000);
                        IsUpdate = false;
                    }


                    
                    //await ShowUpdateDialogAsync(result.NewVersion);
                }
                else if (result.Success)
                {
                    // 已经是最新版本
                    UpdateStatus = "已是最新版本";
                    await Task.Delay(1000);
                    IsUpdate = false;
                }
                else
                {
                    // 检查更新失败
                    UpdateStatus = $"检查更新失败: {result.Message}";
                    IsUpdate = false;
                }
            }
            catch (Exception ex)
            {
                UpdateStatus = $"检查更新时发生错误: {ex.Message}";
            }
            finally
            {
                _isCheckingForUpdates = false;
            }
        }

       

       

        private async Task DownloadAndInstallUpdateAsync(HYPMView.AutoUpdate.VersionInfo versionInfo)
        {
            UpdateStatus = "开始下载更新...";
            DownloadProgress = 0;

            try
            {
                var currentDir = AppContext.BaseDirectory;

                // 🎯 根据平台选择下载URL
                var downloadUrl = GetPlatformSpecificDownloadUrl(versionInfo);
                var packagePath = Path.Combine(currentDir, GetPackageFileName());

                //这里是自定义的日志记录服务,应该替换成自己的日志记录服务或者直接控制台输出,后面类似的内容做同样的处理
                BaseClass.Logger.Info($"开始下载更新包,URL: {downloadUrl}");
                BaseClass.Logger.Info($"更新包保存路径: {packagePath}");

                // 下载文件
                using var httpClient = new HttpClient();
                using var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
                response.EnsureSuccessStatusCode();

                var totalBytes = response.Content.Headers.ContentLength ?? -1L;
                var totalBytesRead = 0L;

                // 🎯 添加下载速度计算相关变量
                var stopwatch = System.Diagnostics.Stopwatch.StartNew();
                var lastUpdateTime = DateTime.Now;
                var lastBytesRead = 0L;
                var speedUpdateInterval = TimeSpan.FromMilliseconds(300); // 每500ms更新一次速度

                using (var contentStream = await response.Content.ReadAsStreamAsync())
                using (var fileStream = new FileStream(packagePath, FileMode.Create, FileAccess.Write, FileShare.None))
                {
                    var buffer = new byte[8192];
                    int bytesRead;

                    while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0)
                    {
                        await fileStream.WriteAsync(buffer, 0, bytesRead);
                        totalBytesRead += bytesRead;

                        // 🎯 计算下载速度
                        var currentTime = DateTime.Now;
                        var timeElapsed = currentTime - lastUpdateTime;

                        if (timeElapsed >= speedUpdateInterval)
                        {
                            var bytesSinceLastUpdate = totalBytesRead - lastBytesRead;
                            var speedKBps = bytesSinceLastUpdate / timeElapsed.TotalSeconds / 1024.0;

                            DownloadSpeed = Math.Round(speedKBps, 1);
                            lastBytesRead = totalBytesRead;
                            lastUpdateTime = currentTime;
                        }

                        // 更新进度
                        if (totalBytes > 0)
                        {
                            var percentage = (double)totalBytesRead / totalBytes * 100;
                            DownloadProgress = percentage;

                            // 🎯 在状态信息中显示下载速度
                            UpdateStatus = $"下载中: {percentage:F1}% (速度: {DownloadSpeed} KB/s)";
                        }
                    }
                }

                BaseClass.Logger.Info($"更新包下载完成,大小: {totalBytesRead} 字节");

                // 🎯 下载完成后直接创建解压脚本并退出程序
                UpdateStatus = "下载完成,准备重启解压...";
                DownloadProgress = 100;

                BaseClass.Logger.Info("创建跨平台解压脚本...");
                await CreateCrossPlatformExtractScript(packagePath, currentDir, versionInfo.Version);

                BaseClass.Logger.Info("准备退出程序进行解压...");
                await Task.Delay(1000);

                // 🎯 调用重启方法执行解压脚本并退出程序
                RestartApplication();
            }
            catch (Exception ex)
            {
                BaseClass.Logger.Error($"更新失败: {ex.Message}");
                UpdateStatus = $"更新失败: {ex.Message}";
                DownloadProgress = 0;
                IsUpdate = false;
            }
        }

       
        /// <summary>
        /// 获取包文件名(根据平台)
        /// </summary>
        private string GetPackageFileName()
        {
            //if (OperatingSystem.IsWindows())
            //    return "update.zip";
            //else
            //    return "update.tar.gz";

            return "update.zip";
        }

        /// <summary>
        /// 获取平台特定的下载URL
        /// </summary>
        private string GetPlatformSpecificDownloadUrl(HYPMView.AutoUpdate.VersionInfo versionInfo)
        {
            var platform = GetPlatformIdentifier();

            // 如果版本信息已经有特定平台的URL,使用它
            if (versionInfo is ExtendedVersionInfo extendedInfo && extendedInfo.PlatformUrls.ContainsKey(platform))
            {
                return extendedInfo.PlatformUrls[platform];
            }

            // 否则从基础URL构造
            var baseUrl = versionInfo.DownloadUrl;
            if (baseUrl.Contains("{platform}"))
            {
                return baseUrl
                    .Replace("{platform}", platform)
                    .Replace("{version}", versionInfo.Version);
            }

            // 如果URL不包含平台占位符,直接返回
            return baseUrl;
        }

        /// <summary>
        /// 获取平台标识符
        /// </summary>
        private string GetPlatformIdentifier()
        {
            if (OperatingSystem.IsWindows()) return "win-x64";
            if (OperatingSystem.IsLinux()) return "linux-x64";
            if (OperatingSystem.IsMacOS()) return "osx-x64";
            if (OperatingSystem.IsAndroid()) return "android";
            if (OperatingSystem.IsIOS()) return "ios";
            return "unknown";
        }

        /// <summary>
        /// 创建跨平台解压脚本
        /// </summary>
        private async Task CreateCrossPlatformExtractScript(string packagePath, string extractDir, string version)
        {
            try
            {
                BaseClass.Logger.Info($"创建解压脚本,版本: {version}, 包路径: {packagePath}");

                if (OperatingSystem.IsWindows())
                {
                    await CreateWindowsExtractScript(packagePath, extractDir, version);
                }
                else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
                {
                    await CreateLinuxExtractScript(packagePath, extractDir, version);
                }
                else
                {
                    await CreateLinuxExtractScript(packagePath, extractDir, version);
                }

                BaseClass.Logger.Info("解压脚本创建成功");
            }
            catch (Exception ex)
            {
                BaseClass.Logger.Error($"创建解压脚本失败: {ex.Message}");
                //throw;
            }
        }
 
        /// <summary>
        /// 创建Windows解压脚本
        /// </summary>
        private async Task CreateWindowsExtractScript(string packagePath, string extractDir, string version)
        {
            var batPath = Path.Combine(extractDir, "apply_update.bat");

            var batContent = $@"
@echo off
chcp 65001 >nul
echo [$(date)] 开始应用更新 v{version}... >> update.log

echo 等待程序完全退出...
timeout /t 3 /nobreak >nul

echo 正在解压更新包...
echo [$(date)] 开始解压更新包 >> update.log
";

            // 根据文件类型选择解压命令
            if (packagePath.EndsWith(".zip"))
            {
                batContent += $@"tar -xf ""{packagePath}"" -C ""{extractDir}""";
            }
            else if (packagePath.EndsWith(".tar.gz"))
            {
                batContent += $@"tar -xzf ""{packagePath}"" -C ""{extractDir}""";
            }

            batContent += $@"

if %errorlevel% equ 0 (
    echo [$(date)] 解压成功 >> update.log
    echo 清理临时文件...
    del ""{packagePath}""
    del ""%~f0""
    echo [$(date)] 临时文件清理完成 >> update.log
    
    echo 启动应用程序...
    echo [$(date)] 启动主程序 >> update.log
    cd /d ""{extractDir}""
    start """" ""{Environment.ProcessPath}""
) else (
    echo [$(date)] 解压失败,错误代码: %errorlevel% >> update.log
    echo 解压失败,请手动解压: {packagePath}
    echo 到目录: {extractDir}
    pause
)
";

            await File.WriteAllTextAsync(batPath, batContent, Encoding.UTF8);
            BaseClass.Logger.Info($"Windows解压脚本已创建: {batPath}");
        }

        /// <summary>
        /// 简化版Linux解压脚本创建
        /// </summary>
        private async Task CreateLinuxExtractScript(string packagePath, string extractDir, string version)
        {
            var shPath = Path.Combine(extractDir, "apply_update.sh");

            // 🎯 逐行构建脚本,确保Unix换行符
            var scriptLines = new List<string>
    {
        "#!/bin/bash",
        "",
        "# 设置日志文件",
        $"LOG_FILE=\"{extractDir}/update.log\"",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 开始应用更新 v{version}...\" >> $LOG_FILE",
        "",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 当前目录: $(pwd)\" >> $LOG_FILE",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 包路径: {packagePath}\" >> $LOG_FILE",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压目录: {extractDir}\" >> $LOG_FILE",
        "",
        "# 等待程序完全退出",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 等待程序完全退出...\" >> $LOG_FILE",
        "sleep 5",
        "",
        "# 检查包文件是否存在",
        $"if [ ! -f \"{packagePath}\" ]; then",
        $"    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 错误: 更新包不存在: {packagePath}\" >> $LOG_FILE",
        "    exit 1",
        "fi",
        "",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 开始解压更新包...\" >> $LOG_FILE",
        "",
        "# 解压zip文件",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 使用unzip解压zip文件\" >> $LOG_FILE",
        $"unzip -o \"{packagePath}\" -d \"{extractDir}\"",
        "EXIT_CODE=$?",
        "",
        $"echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压退出代码: $EXIT_CODE\" >> $LOG_FILE",
        "",
        "if [ $EXIT_CODE -eq 0 ]; then",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压成功\" >> $LOG_FILE",
        "    ",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 清理临时文件...\" >> $LOG_FILE",
        $"    rm -f \"{packagePath}\"",
        "    rm -f \"$0\"",
        "    ",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 设置执行权限...\" >> $LOG_FILE",
        $"    chmod +x \"{extractDir}/HYPMView.Desktop\"",
        "    ",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 启动应用程序...\" >> $LOG_FILE",
        $"    cd \"{extractDir}\"",
        "    ./HYPMView.Desktop",
        "    ",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 应用程序已启动\" >> $LOG_FILE",
        "else",
        "    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] 解压失败\" >> $LOG_FILE",
        $"    echo \"请手动解压: {packagePath}\" >> $LOG_FILE",
        $"    echo \"到目录: {extractDir}\" >> $LOG_FILE",
        "    exit 1",
        "fi"
    };

            // 🎯 使用Unix换行符连接所有行
            var shContent = string.Join("\n", scriptLines);

            // 写入文件,使用UTF-8无BOM编码
            await File.WriteAllTextAsync(shPath, shContent, new UTF8Encoding(false));

            // 设置执行权限
            await SetFileExecutable(shPath);
            BaseClass.Logger.Info($"Linux解压脚本已创建: {shPath}");
        }

        /// <summary>
        /// 设置文件可执行权限(Linux)
        /// </summary>
        private async Task SetFileExecutable(string filePath)
        {
            if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
            {
                try
                {
                    using var process = new Process();
                    process.StartInfo.FileName = "chmod";
                    process.StartInfo.Arguments = $"+x \"{filePath}\"";
                    process.StartInfo.UseShellExecute = false;
                    process.StartInfo.CreateNoWindow = true;

                    process.Start();
                    await process.WaitForExitAsync();
                    BaseClass.Logger.Info($"文件执行权限设置成功: {filePath}");
                }
                catch (Exception ex)
                {
                    BaseClass.Logger.Error($"设置文件执行权限失败: {ex.Message}");
                }
            }
        }

        // 🎯 扩展版本信息类(如果需要)
        public class ExtendedVersionInfo : HYPMView.AutoUpdate.VersionInfo
        {
            public Dictionary<string, string> PlatformUrls { get; set; } = new Dictionary<string, string>();
        }
        /// <summary>
        /// 重启当前应用程序
        /// </summary>
        private void RestartApplication()
        {
            try
            {
                var currentDir = AppContext.BaseDirectory;

                BaseClass.Logger.Info("开始执行重启流程");

                if (OperatingSystem.IsWindows())
                {
                    var batPath = Path.Combine(currentDir, "apply_update.bat");
                    if (File.Exists(batPath))
                    {
                        BaseClass.Logger.Info("执行Windows解压脚本");
                        Process.Start(new ProcessStartInfo
                        {
                            FileName = "cmd.exe",
                            Arguments = $"/c \"{batPath}\"",
                            UseShellExecute = true,
                            CreateNoWindow = false
                        });
                    }
                    else
                    {
                        BaseClass.Logger.Warn("解压脚本不存在,直接重启");
                        Process.Start(new ProcessStartInfo
                        {
                            FileName = Environment.ProcessPath,
                            WorkingDirectory = currentDir,
                            UseShellExecute = true
                        });
                    }
                }
                else
                {
                    var shPath = Path.Combine(currentDir, "apply_update.sh");
                    if (File.Exists(shPath))
                    {
                        BaseClass.Logger.Info("执行Linux解压脚本");

                        // 🎯 修复:使用bash直接执行脚本,而不是nohup
                        Process.Start(new ProcessStartInfo
                        {
                            FileName = "bash",
                            Arguments = $"\"{shPath}\"",
                            UseShellExecute = true,  // 使用Shell执行
                            WorkingDirectory = currentDir
                        });
                    }
                    else
                    {
                        BaseClass.Logger.Warn("解压脚本不存在,直接重启");
                        Process.Start(new ProcessStartInfo
                        {
                            FileName = Environment.ProcessPath,
                            WorkingDirectory = currentDir,
                            UseShellExecute = true
                        });
                    }
                }

                BaseClass.Logger.Info("程序退出,等待解压脚本执行");
                Environment.Exit(0);
            }
            catch (Exception ex)
            {
                BaseClass.Logger.Error($"重启失败: {ex.Message}");
                UpdateStatus = $"重启失败,请手动重启应用。错误: {ex.Message}";
            }
        }

       

      

       
      
        /// <summary>
        /// 获取当前应用程序版本
        /// </summary>
        /// <returns>当前版本字符串</returns>
        private string GetCurrentVersion()
        {
            
            return VersionDto.Version;
        }

        /// <summary>
        /// 格式化文件大小显示
        /// </summary>
        /// <param name="bytes">字节数</param>
        /// <returns>格式化的文件大小字符串</returns>
        private string FormatFileSize(long bytes)
        {
            string[] sizes = { "B", "KB", "MB", "GB" };
            int order = 0;
            double len = bytes;

            while (len >= 1024 && order < sizes.Length - 1)
            {
                order++;
                len = len / 1024;
            }

            return $"{len:0.##} {sizes[order]}";
        }
相关推荐
LCG元2 小时前
CI/CD 实战:用 Jenkins 自动构建和部署你的项目
linux
阿干tkl2 小时前
Linux 虚拟机模板制作指南
linux·vmware
Aries·Zhao3 小时前
.NET 6 ~ .NET 9 技术演进与区别分析:从稳定旗舰到性能王者
.net
裤裤兔3 小时前
linux提取指定前缀文件到其他文件夹
linux·运维·服务器·bash·终端
mucheni3 小时前
迅为RK3568开发板OpeHarmony学习开发手册-修改应用程序名称
linux·前端·学习
CS_浮鱼3 小时前
【Linux】进程间通信
linux·运维·数据库
Altair12313 小时前
实验6 基于端口和域名的虚拟主机
linux·运维·服务器·云计算
爱和冰阔落3 小时前
【Linux工具链】编译效率革命:条件编译优化+动静态库管理+Makefile自动化,解决多场景开发痛点
linux·运维·自动化
wa的一声哭了3 小时前
WeBASE管理平台部署-WeBASE-Web
linux·前端·网络·arm开发·spring boot·架构·区块链