RPC远程过程调用,二进制封装,提供高吞吐低延迟的高性能RPC框架
大石头 authored at 2022-08-10 13:26:19
30.07 KiB
NewLife.Remoting
using System.Diagnostics;
using System.Net.Http;
using System.Reflection;
using NewLife.Log;
using NewLife.Remoting.Models;
#if !NET40
using TaskEx = System.Threading.Tasks.Task;
#endif

namespace NewLife.Remoting.Clients;

/// <summary>升级更新</summary>
/// <remarks>
/// 自动更新的难点在于覆盖正在使用的exe/dll文件,通过改名可以解决。
/// </remarks>
public class Upgrade
{
    #region 属性
    /// <summary>名称</summary>
    public String Name { get; set; } = null!;

    /// <summary>更新目录。默认./Update</summary>
    public String? UpdatePath { get; set; } = "Update";

    /// <summary>目标目录</summary>
    public String? DestinationPath { get; set; } = ".";

    /// <summary>源文件下载地址</summary>
    public String? Url { get; set; }

    /// <summary>更新源文件</summary>
    public String? SourceFile { get; set; }

    /// <summary>解压缩的临时目录</summary>
    public String? TempPath { get; set; }

    /// <summary>更新模式</summary>
    public UpdateModes Mode { get; set; } = UpdateModes.Standard;

    /// <summary>下载超时时间(秒)。默认30秒</summary>
    public Int32 DownloadTimeoutSeconds { get; set; } = 30;

    /// <summary>落盘等待延迟(毫秒)。sync 后等待存储设备完成回写。Linux ARM64 慢速存储(eMMC/SD)默认 800ms,x86 默认 0</summary>
    public Int32 StorageFlushDelay { get; set; } = GetDefaultFlushDelay();

    private static Int32 GetDefaultFlushDelay()
    {
#if NETCOREAPP || NETSTANDARD2_0_OR_GREATER
        if (Runtime.Linux && System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm64)
            return 800;
#endif
        return 0;
    }
    #endregion

    #region 最后错误
    /// <summary>最后一次启动进程的错误消息,用于外部诊断。在 systemd 等隔离环境下,Process.Start 失败的原因无法直接通过返回布尔值表达,此属性可将 catch 的异常详情透传给调用方,最终输出到节点历史(WriteInfoEvent)</summary>
    public String? LastErrorMessage { get; private set; }
    #endregion

    #region 构造
    /// <summary>实例化一个升级对象实例,获取当前应用信息</summary>
    public Upgrade()
    {
        var asm = Assembly.GetEntryAssembly();
        Name = asm?.GetName().Name ?? nameof(Upgrade);
    }
    #endregion

    #region 方法
    /// <summary>下载更新包,支持超时与瞬时失败重试</summary>
    public virtual async Task<Boolean> Download(CancellationToken cancellationToken = default)
    {
        var url = Url;
        if (url.IsNullOrEmpty()) return false;

        var fileName = Path.GetFileName(url);
        if (fileName.IsNullOrEmpty() || fileName.Contains('?')) fileName = "a.zip";

        // 即使更新包存在,也要下载
        var file = UpdatePath.CombinePath(fileName).GetBasePath();
        if (File.Exists(file)) File.Delete(file); ;

        WriteLog("准备下载 {0} 到 {1}", url, file);

        var sw = Stopwatch.StartNew();

        var web = CreateClient();

        // 瞬时网络故障重试两次(共三次尝试)
        var retry = 2;
        while (true)
        {
            try
            {
                file = await DownloadFileAsync(web, url, file, cancellationToken).ConfigureAwait(false);
                break;
            }
            catch (Exception ex) when (ex is IOException || ex is HttpRequestException)
            {
                if (retry-- <= 0) throw;
                WriteLog("下载失败,2秒后重试:{0}", ex.Message);
                await TaskEx.Delay(2_000, cancellationToken).ConfigureAwait(false);
            }
        }

        sw.Stop();
        WriteLog("下载完成!{2} 大小{0:n0}字节,耗时{1:n0}ms", file.AsFile().Length, sw.ElapsedMilliseconds, file);

        var md5 = file.AsFile().MD5().ToHex();
        WriteLog("MD5: {0}", md5);

        SourceFile = file;

        return true;
    }

    /// <summary>
    /// 检查文件散列,避免文件损坏
    /// </summary>
    /// <param name="hash"></param>
    /// <returns></returns>
    public Boolean CheckFileHash(String hash)
    {
        if (hash.IsNullOrEmpty()) return false;

        var fi = SourceFile?.AsFile();
        if (fi == null || !fi.Exists) return false;

        var md5 = fi.MD5().ToHex();
        return md5.EqualIgnoreCase(hash);
    }

    /// <summary>解压缩</summary>
    /// <returns></returns>
    public virtual Boolean Extract()
    {
        var file = SourceFile;
        if (file.IsNullOrEmpty() || !File.Exists(file)) return false;

        WriteLog("发现更新包 {0}", file);

        // 解压更新程序包
        if (!file.EndsWithIgnoreCase(".zip")) return false;

        var tmp = TempPath;
        if (tmp.IsNullOrEmpty()) tmp = TempPath = Path.GetTempPath().CombinePath(Path.GetFileNameWithoutExtension(file));
        WriteLog("解压缩到临时目录 {0}", tmp);
        file.AsFile().Extract(tmp, true);

        return true;
    }

    /// <summary>执行更新,拷贝文件。按更新模式清理目标目录</summary>
    public virtual Boolean Update()
    {
        var dest = DestinationPath;
        if (dest.IsNullOrEmpty()) return false;

        // 删除备份文件
        DeleteBackup(dest);

        var tmp = TempPath;
        if (tmp.IsNullOrEmpty() || !Directory.Exists(tmp)) return false;

        WriteLog("发现更新源目录 {0}", tmp);

        // 记录移动文件,更新失败时恢复
        var dic = new Dictionary<String, String>();
        try
        {
            switch (Mode)
            {
                case UpdateModes.Full:
                    // 完整包:清空目标目录(保护配置、更新目录、备份和日志)
                    WriteLog("完整包模式,清空目标目录");
                    {
                        var protectedNames = new[] { "appsettings.json", "appsettings.*.json", "Update", "Log", "Config" };
                        foreach (var item in dest.AsDirectory().GetAllFiles(null, false))
                        {
                            if (!item.Name.EndsWithIgnoreCase(".del") && !protectedNames.Any(e => item.Name.EqualIgnoreCase(e)))
                            {
                                var ori = item.FullName;
                                var del = $"{ori}.del";
                                if (File.Exists(del)) del = $"{ori}_{DateTime.Now:yyMMddHHmmss}.del";
                                WriteLog("MoveTo {0}", del);
                                item.MoveTo(del);
                                dic[ori] = del;
                            }
                        }
                        // 递归清理子目录(保留 Update/ Log/ Config/)
                        foreach (var dir in dest.AsDirectory().GetDirectories())
                        {
                            if (protectedNames.Any(e => dir.Name.EqualIgnoreCase(e))) continue;
                            WriteLog("DeleteDir {0}", dir.FullName);
                            dir.Delete(true);
                        }
                    }
                    break;
                case UpdateModes.Partial:
                    // 部分包:不清理目标,直接覆盖
                    WriteLog("部分包模式,直接覆盖");
                    break;
                case UpdateModes.Standard:
                default:
                    //!!! 此处递归删除,导致也删掉了Update里面的文件
                    // 更新覆盖之前,需要把exe/dll可执行文件移走,否则Linux下覆盖运行中文件会报段错误
                    foreach (var item in dest.AsDirectory().GetAllFiles("*.exe;*.dll", false))
                    {
                        var ori = item.FullName;
                        var del = $"{item.FullName}.del";
                        WriteLog("MoveTo {0}", del);
                        try
                        {
                            //if (File.Exists(del)) File.Delete(del);
                            // 如果.del文件已存在,不能直接删,因为进程可能正在使用(上次升级未完成)。
                            if (File.Exists(del)) del = $"{item.FullName}_{DateTime.Now:yyMMddHHmmss}.del";
                            item.MoveTo(del);

                            dic[ori] = del;
                        }
                        catch (Exception ex)
                        {
                            WriteLog(ex.Message);

                            try
                            {
                                // 删除失败时,移动到临时目录随机文件
                                var target = Path.GetTempFileName();
                                item.MoveTo(target);

                                dic[ori] = target;
                            }
                            catch (Exception ex2)
                            {
                                WriteLog(ex2.Message);
                            }
                        }
                    }

                    // 备份将被覆盖的 JSON 文件(runtimeconfig.json / deps.json 等随 .NET 版本变化的文件),确保 Rollback 能完整恢复
                    {
                        var jsonPatterns = new[] { "*.runtimeconfig.json", "*.deps.json" };
                        foreach (var pattern in jsonPatterns)
                        {
                            foreach (var item in dest.AsDirectory().GetAllFiles(pattern, false))
                            {
                                // 跳过已在 exe/dll 环节备份的文件
                                if (dic.ContainsKey(item.FullName)) continue;

                                var ori = item.FullName;
                                var del = $"{ori}.del";
                                WriteLog("MoveTo {0}", del);
                                try
                                {
                                    if (File.Exists(del)) del = $"{ori}_{DateTime.Now:yyMMddHHmmss}.del";
                                    item.MoveTo(del);
                                    dic[ori] = del;
                                }
                                catch (Exception ex)
                                {
                                    WriteLog(ex.Message);
                                }
                            }
                        }
                    }
                    break;
            }

            // 拷贝替换更新
            CopyAndReplace(tmp, dest);

            //// 删除备份文件
            //DeleteBackup(DestinationPath);
            //!!! 先别急着删除,在Linux上,删除正在使用的文件可能导致进程崩溃

            // 强制 fsync 将页缓存提交到存储介质。同一启动周期内 exec() 通过页缓存读取文件是内核级一致的,
            // sync 的主要价值是防止系统崩溃/断电重启后读到半写入数据。
            if (Runtime.Linux)
            {
                Process.Start(new ProcessStartInfo("sync", "") { UseShellExecute = false })?.WaitForExit(10_000);

                // ARM64 慢速存储额外等待页缓存回写完成
                if (StorageFlushDelay > 0)
                {
                    WriteLog("等待落盘 {0}ms(慢速存储保护)", StorageFlushDelay);
                    Thread.Sleep(StorageFlushDelay);
                }
            }

            WriteLog("更新成功!");
        }
        catch
        {
            WriteLog("更新失败,恢复文件");
            Restore(dic);

            throw;
        }

        return true;
    }

    void Restore(IDictionary<String, String> dic)
    {
        foreach (var item in dic)
        {
            WriteLog("Restore {0}", item.Value);
            if (File.Exists(item.Value))
            {
                if (File.Exists(item.Key))
                {
                    WriteLog("Delete {0}", item.Key);

                    try
                    {
                        File.Delete(item.Key);
                    }
                    catch (Exception ex)
                    {
                        WriteLog(ex.Message);
                    }
                }

                try
                {
                    File.Move(item.Value, item.Key);
                }
                catch (Exception ex)
                {
                    WriteLog(ex.Message);
                }
            }
        }
    }

    /// <summary>回滚更新。将目标目录中备份的 .del 文件恢复为原始文件,用于新版本启动失败(冒烟测试不通过)时回退到旧版本</summary>
    /// <remarks>
    /// 设计理念:自更新最怕新版本有 bug 把自己"玩死"。
    /// 更新完成后旧进程尝试拉起新进程作为冒烟测试——若新版本在 msWait 内异常退出,
    /// 说明新版本存在致命缺陷,此时回滚所有 .del 恢复旧版文件,旧进程继续提供服务。
    /// 即使设备随后重启,加载的仍是经过验证的旧版本。
    /// </remarks>
    /// <returns>恢复的文件数量</returns>
    public Int32 Rollback()
    {
        var dest = DestinationPath;
        if (dest.IsNullOrEmpty()) return 0;

        var count = 0;
        // 扫描目标目录中的 .del 文件(含时间戳变体 xxx_yyMMddHHmmss.del)
        foreach (var item in dest.AsDirectory().GetAllFiles("*.del", false))
        {
            var delName = item.FullName;
            // 去掉 .del 后缀得到原始文件名
            var ori = delName.TrimSuffix(".del");

            // 时间戳变体:xxx_yyMMddHHmmss.del → 原始文件名为 xxx(去掉 _12位时间戳)
            var p = ori.LastIndexOf('_');
            if (p > 0)
            {
                var suffix = ori.Substring(p + 1);
                // 判断是否为 12 位纯数字时间戳(避免误判含下划线的正常文件名)
                if (suffix.Length == 12 && suffix.All(Char.IsDigit))
                    ori = ori.Substring(0, p);
            }

            WriteLog("Rollback {0} -> {1}", item.Name, Path.GetFileName(ori));
            try
            {
                if (File.Exists(ori)) File.Delete(ori);
                File.Move(delName, ori);
                count++;
            }
            catch (Exception ex)
            {
                WriteLog("回滚失败:{0}", ex.Message);
            }
        }

        if (count > 0) WriteLog("回滚完成,共恢复 {0} 个文件", count);

        return count;
    }

    /// <summary>启动当前应用的新进程。当前进程退出</summary>
    public Boolean Run(String name, String args) => Run(name, args, 3_000);

    /// <summary>启动当前应用的新进程</summary>
    /// <param name="name">应用名称(不含扩展名)。如 StarAgent</param>
    /// <param name="args">启动参数</param>
    /// <param name="msWait">等待毫秒数。若进程在此期间未退出视为启动成功</param>
    /// <returns>是否成功拉起</returns>
    public Boolean Run(String name, String args, Int32 msWait)
    {
        // 通过 runtimeconfig.json 判定是否为 .NET Core 应用(muxer 优先)
        var runtimeConfig = $"{name}.runtimeconfig.json".GetFullPath();
        var isNetCore = File.Exists(runtimeConfig);

        String fileName;
        String? dotnetPath = null;
        if (isNetCore)
        {
            // .NET Core 应用:使用 dotnet <name>.dll <args>,跨平台标准写法
            fileName = (name + ".dll").GetFullPath();
            if (!File.Exists(fileName)) throw new FileNotFoundException($"未找到 .NET 应用入口:{fileName}");

            dotnetPath = ResolveDotNetPath();
        }
        else
        {
            // 非 .NET Core 应用:按平台选择可执行文件
            if (Runtime.Windows || Runtime.Mono)
                fileName = (name + ".exe").GetFullPath();
            else if (Runtime.Linux)
                fileName = name.GetFullPath();
            else
                fileName = (name + ".dll").GetFullPath();

            // 如果入口文件不存在,则尝试 dll 启动(.NET Framework 也可能用 dotnet 启动)
            if (!File.Exists(fileName))
            {
                var dllFile = (name + ".dll").GetFullPath();
                if (File.Exists(dllFile))
                {
                    fileName = dllFile;
                    dotnetPath = ResolveDotNetPath();
                    isNetCore = true;
                }
            }

            if (!File.Exists(fileName)) throw new FileNotFoundException($"未找到可执行文件:{fileName}");
        }

        // Linux 上对新二进制文件执行 chmod +x,确保有执行权限
        if (Runtime.Linux && !isNetCore)
        {
            // 等待 chmod 完成(WaitForExit),避免 fire-and-forget 导致权限未及时生效的竞态条件。
            // 在慢速 ARM64 设备上,升级刚完成后系统 I/O 繁忙,chmod 可能超过 1 秒才能完成,
            // 若此时新进程尚无执行权限,UseShellExecute=false 会抛出 EACCES 导致启动失败。
            // 使用绝对路径 /bin/chmod 避免 PATH 解析,UseShellExecute=false 确保同步等待。
            var p = Process.Start(new ProcessStartInfo("/bin/chmod", "+x " + fileName) { UseShellExecute = false });
            p?.WaitForExit(5_000);
        }

        WriteLog("拉起进程 {0} {1}", fileName, args);
        try
        {
            var workingDir = Path.GetDirectoryName(fileName)!;
            Process? p;

            if (isNetCore)
            {
                var startInfo = new ProcessStartInfo
                {
                    FileName = dotnetPath!,
                    Arguments = $"{fileName} {args}",
                    WorkingDirectory = workingDir,
                    UseShellExecute = false,
                    RedirectStandardError = true,
                };

                // 注入 DOTNET_ROOT 环境变量,确保子进程在 systemd 隔离环境下找到运行时
                InjectDotNetRoot(startInfo, dotnetPath!);

                p = Process.Start(startInfo);
            }
            else
            {
                // 直接执行原生可执行文件(Linux 上为无扩展名的二进制文件),同样避免 shell 层
                p = Process.Start(new ProcessStartInfo
                {
                    FileName = fileName,
                    Arguments = args,
                    WorkingDirectory = workingDir,
                    UseShellExecute = false,
                    RedirectStandardError = true,
                });
            }

            if (p == null) return false;

            // 如果进程在指定时间内退出,说明启动失败,捕获 stderr 用于诊断
            if (p.WaitForExit(msWait))
            {
                if (p.ExitCode == 0) return true;

                // 非零退出:读取 stderr 写入 LastErrorMessage
                var stderr = p.StandardError.ReadToEnd();
                LastErrorMessage = !stderr.IsNullOrEmpty()
                    ? $"ExitCode={p.ExitCode}, stderr={stderr}"
                    : $"ExitCode={p.ExitCode}(无错误输出)";
                WriteLog("启动进程失败:{0}", LastErrorMessage);
                return false;
            }

            // 进程持续运行 → 成功
            return true;
        }
        catch (Exception ex)
        {
            // 保存完整异常信息供调用方通过 WriteInfoEvent 上报到节点历史,
            // 方便用户在 Web 管理界面直接看到失败原因(如 dotnet 未找到、文件无权限等)
            LastErrorMessage = ex.ToString();
            WriteLog("启动进程失败:{0}", ex);
            return false;
        }
    }

    /// <summary>解析 dotnet 可执行文件路径</summary>
    /// <remarks>
    /// 优先级:DOTNET_ROOT 环境变量 → /usr/bin/dotnet(Linux 标准软链接,NetRuntime.InstallOnLinux 创建)→ /usr/local/bin/dotnet → 回退 "dotnet"
    /// </remarks>
    /// <returns>dotnet 可执行文件路径</returns>
    internal static String ResolveDotNetPath()
    {
        // 优先使用 DOTNET_ROOT 环境变量
        var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
        if (!dotnetRoot.IsNullOrEmpty())
        {
            var path = Path.Combine(dotnetRoot, "dotnet");
            if (File.Exists(path)) return path;
        }

        // Linux 标准路径:/usr/bin/dotnet(NetRuntime.InstallOnLinux 创建的软链接)
        if (Runtime.Linux)
        {
            if (File.Exists("/usr/bin/dotnet")) return "/usr/bin/dotnet";
            if (File.Exists("/usr/local/bin/dotnet")) return "/usr/local/bin/dotnet";
        }

        // 回退到系统 PATH
        return "dotnet";
    }

    /// <summary>向子进程注入 DOTNET_ROOT 环境变量,确保在 systemd 隔离环境下找到运行时</summary>
    /// <param name="startInfo">进程启动信息</param>
    /// <param name="dotnetPath">已解析的 dotnet 路径</param>
    static void InjectDotNetRoot(ProcessStartInfo startInfo, String dotnetPath)
    {
#if NET5_0_OR_GREATER
        // 如果 dotnetPath 是绝对路径,反推运行时根目录
        if (Path.IsPathRooted(dotnetPath) && Runtime.Linux)
        {
            // /usr/bin/dotnet → /usr, /usr/share/dotnet/dotnet → /usr/share/dotnet
            var binDir = Path.GetDirectoryName(dotnetPath)!;
            var dotnetRoot = Path.GetDirectoryName(binDir);

            // /usr/bin/dotnet → 运行时在 /usr/share/dotnet 而不是 /usr
            if (dotnetRoot != null && (dotnetRoot.EndsWith("/bin") || dotnetRoot!.EndsWith("\\bin")))
                dotnetRoot = Path.Combine(Path.GetDirectoryName(dotnetRoot)!, "share", "dotnet");

            if (!dotnetRoot.IsNullOrEmpty() && Directory.Exists(dotnetRoot))
                startInfo.Environment["DOTNET_ROOT"] = dotnetRoot;
        }

        // 注入当前进程的 DOTNET_ROOT(如果有)
        var currentRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
        if (!currentRoot.IsNullOrEmpty() && !startInfo.Environment.ContainsKey("DOTNET_ROOT"))
            startInfo.Environment["DOTNET_ROOT"] = currentRoot;

        // 注入架构特定的 DOTNET_ROOT 变量(ARM64/x64)
        if (startInfo.Environment.TryGetValue("DOTNET_ROOT", out var root) && !root.IsNullOrEmpty())
        {
            var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture;
            if (arch == System.Runtime.InteropServices.Architecture.Arm64)
                startInfo.Environment["DOTNET_ROOT_ARM64"] = root;
            else if (arch == System.Runtime.InteropServices.Architecture.X64)
                startInfo.Environment["DOTNET_ROOT_X64"] = root;
        }
#else
        // .NET Framework:使用 EnvironmentVariables
        if (Path.IsPathRooted(dotnetPath) && Runtime.Linux)
        {
            var binDir = Path.GetDirectoryName(dotnetPath)!;
            var dotnetRoot = Path.GetDirectoryName(binDir);

            if (dotnetRoot != null && (dotnetRoot.EndsWith("/bin") || dotnetRoot!.EndsWith("\\bin")))
                dotnetRoot = Path.Combine(Path.GetDirectoryName(dotnetRoot)!, "share", "dotnet");

            if (!dotnetRoot.IsNullOrEmpty() && Directory.Exists(dotnetRoot))
                startInfo.EnvironmentVariables["DOTNET_ROOT"] = dotnetRoot;
        }

        var currentRoot2 = Environment.GetEnvironmentVariable("DOTNET_ROOT");
        if (!currentRoot2.IsNullOrEmpty() && !startInfo.EnvironmentVariables.ContainsKey("DOTNET_ROOT"))
            startInfo.EnvironmentVariables["DOTNET_ROOT"] = currentRoot2;
#endif
    }

    static Process? RunShell(String fileName, String args) => Process.Start(new ProcessStartInfo(fileName, args) { UseShellExecute = true });
    #endregion

    #region 辅助
    /// <summary>终止当前进程</summary>
    public virtual void KillSelf()
    {
        var p = Process.GetCurrentProcess();
        WriteLog("退出当前进程 {0}", p.Id);

        if (!Runtime.IsConsole)
            p.CloseMainWindow();

        // 直接退出进程,不再执行任何代码
        Environment.Exit(0);
    }

    /// <summary>
    /// 执行命令,文件名与参数由空格隔开
    /// </summary>
    /// <param name="cmd"></param>
    public void Run(String cmd)
    {
        if (cmd.IsNullOrEmpty()) return;

        WriteLog("执行命令:{0}", cmd);

        var args = "";
        var p = cmd.IndexOf(' ');
        if (p > 0)
        {
            args = cmd.Substring(p + 1);
            cmd = cmd.Substring(0, p);
        }

        RunShell(cmd, args);
    }

    /// <summary>
    /// 清理不属于当前平台的执行文件
    /// </summary>
    /// <param name="name"></param>
    public void Trim(String name)
    {
        var name2 = name.TrimSuffix(".exe", ".dll");
        if (Runtime.Windows || Runtime.Mono)
        {
            var file = name2.GetFullPath();
            if (File.Exists(file)) File.Delete(file);
        }
        else if (Runtime.Linux)
        {
            var file = (name2 + ".exe").GetFullPath();
            if (File.Exists(file)) File.Delete(file);
        }
    }
    #endregion

    #region 辅助
    private HttpClient? _Client;
    private HttpClient CreateClient()
    {
        if (_Client != null) return _Client;

        return _Client = new HttpClient
        {
            Timeout = TimeSpan.FromSeconds(DownloadTimeoutSeconds),
        };
    }

    /// <summary>下载文件</summary>
    /// <param name="client">客户端</param>
    /// <param name="address">地址</param>
    /// <param name="fileName">文件名</param>
    /// <param name="cancellationToken">取消令牌</param>
    public static async Task<String> DownloadFileAsync(HttpClient client, String address, String fileName, CancellationToken cancellationToken = default)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, address);
        var rs = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
        rs.EnsureSuccessStatusCode();

        // 从Http响应头中获取文件名
        var file2 = rs.Content.Headers?.ContentDisposition?.FileName;
        if (!file2.IsNullOrEmpty()) fileName = Path.GetDirectoryName(fileName).CombinePath(file2);
        fileName.EnsureDirectory(true);

        // 删除已存在文件,否则新文件比旧文件小时,写入的文件后面有冗余数据,导致解压缩失败
        if (File.Exists(fileName))
            try
            {
                File.Delete(fileName);
            }
            catch { }

        var ms = rs.Content;
        using var fs = new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
        await ms.CopyToAsync(fs).ConfigureAwait(false);
        await fs.FlushAsync(cancellationToken).ConfigureAwait(false);

        // 截断文件,如果前面删除失败,这里就可能使用旧文件,需要把多余部分截断
        fs.SetLength(fs.Position);

        return fileName;
    }

    /// <summary>删除备份文件</summary>
    /// <param name="dest">目标目录</param>
    public void DeleteBackup(String dest)
    {
        // 删除备份
        var di = dest.AsDirectory();
        var fs = di.GetAllFiles("*.del", true);
        foreach (var item in fs)
        {
            WriteLog("Delete {0}", item);
            try
            {
                item.Delete();
            }
            catch (Exception ex)
            {
                WriteLog(ex.Message);
            }
        }
    }

    /// <summary>拷贝并替换。正在使用锁定的文件不可删除,但可以改名</summary>
    /// <param name="source">源目录</param>
    /// <param name="dest">目标目录</param>
    public void CopyAndReplace(String source, String dest)
    {
        WriteLog("CopyAndReplace {0} => {1}", source, dest);

        var src = source.AsDirectory();

        // 来源目录根,用于截断
        var root = src.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString());
        foreach (var item in src.GetAllFiles(null, true))
        {
            var name = item.FullName.TrimPrefix(root);
            var dst = dest.CombinePath(name).GetBasePath();

            // 如果是应用配置文件,不要更新
            //if (dst.EndsWithIgnoreCase(".exe.config") ||
            //    dst.EqualIgnoreCase("appsettings.json")) continue;
            // net45下,需要更新 StarAgent.exe.config ,里面的assemblyBinding指定了所依赖 NewLife.Core 的版本
            if (dst.EqualIgnoreCase("appsettings.json")) continue;

            // 拷贝覆盖
            WriteLog("Copy {0}", name);
            try
            {
                item.CopyTo(dst.EnsureDirectory(true), true);
            }
            catch (Exception ex)
            {
                WriteLog(ex.Message);

                // 如果是exe/dll,则先改名,因为可能无法覆盖
                if (/*dst.EndsWithIgnoreCase(".exe", ".dll") &&*/ File.Exists(dst))
                {
                    //// 先尝试删除
                    //WriteLog("Delete {0}", item);
                    //try
                    //{
                    //    File.Delete(dst);
                    //}
                    //catch
                    //{
                    // 直接Move文件,不要删除,否则Linux上可能导致当前进程退出
                    WriteLog("Move {0}", item);
                    var del = $"{dst}.del";
                    //if (File.Exists(del)) File.Delete(del);
                    // 如果.del文件已存在,不能直接删,因为进程可能正在使用(上次升级未完成)。
                    if (File.Exists(del)) del = $"{dst}_{DateTime.Now:yyMMddHHmmss}.del";
                    File.Move(dst, del);
                    //}

                    item.CopyTo(dst, true);
                }
            }
        }

        // 删除临时目录
        WriteLog("Delete {0}", src.FullName);
        src.Delete(true);
    }
    #endregion

    #region 日志
    /// <summary>日志对象</summary>
    public ILog Log { get; set; } = Logger.Null;

    /// <summary>输出日志</summary>
    /// <param name="format"></param>
    /// <param name="args"></param>
    public void WriteLog(String format, params Object?[] args) => Log?.Info($"[{Name}]{format}", args);
    #endregion
}