简介fog-client是一款基于C#开发的跨平台计算机管理客户端面向系统管理员与IT运维人员解决Windows、Linux、macOS环境下的远程集中管理问题。资源包为zip格式共304个文件压缩包整体仅7.7MB文件类型以cs源码、resx界面资源、config配置文件为主同时包含plist、sh等跨平台配置与脚本便于二次开发与自定义部署。目前已有169人学习下载。包内提供完整客户端源码及构建相关配置覆盖自动登出、自动更新、能源管理、设备改名、Active Directory/Samba/Open Directory目录加入以及TCP/IP、CUPS等多样打印机管理模块。无论是希望研究跨平台客户端架构的开发者还是需要对接FOG服务器的运维人员都能从中获得可直接参考的实现思路与可编译的基础工程。1. 快速判断fog-client 凭什么值得你下内网里几十台 Windows 和 Linux 机器需要统一纳管的时候大家的第一反应通常是 Ansible 或者 SaltStack。但如果你要的是一套能嵌进自己运维平台、采集项和上报协议都能定制、跑起来就一个进程的轻量客户端fog-client 这种 C# 实现的方案反而更顺手。它本质是一个跨平台计算机管理客户端定时采集 CPU、内存、磁盘和系统标识把数据以 JSON 上报到服务端同时轮询远程任务执行完回传结果。适合在做资产盘点、运维自动化或边缘设备管理、且团队有 C# 技术栈的开发者。最直接的价值是部署成本可控不依赖目标机预装运行时一条发布命令就能压出 Windows 和 Linux 两个平台的单文件程序。下面把它的模块划分、核心代码、部署方式和坑一个个拆开。2. 客户端底座C# 跨平台的模块划分与选型取舍2.1 为什么选 C#管理客户端技术路线的取舍管理客户端这个场景候选语言通常就三门Python、Go、C#。我以前用 Python 写过一版采集代理上线第一个月就遇到两个问题一是打包出来的目录结构在无图形环境里看着像黑匣子二是每次升级都要处理系统和 Python 版本错位。后来切到 Go 试过编译产物确实干净但团队里没人愿意维护复杂的状态机而且遇到 Windows 域环境、WMI 这类系统 API总觉得隔了一层。C# 的路数不一样它在 Windows 上原生能碰 WMI、注册表和性能计数器在 Linux 上又能通过 /proc 和 /sys 拿系统信息两边都能用同一套 async/await 去组织采集流程。加上 .NET 5 以后的自包含发布目标机不需要预装运行时单个可执行文件带着 runtime 跑跟 Go 的部署形态差不多。维度C#.NET 5PythonGo部署产物单文件 自带运行时目录、虚拟环境单文件Windows 系统 API原生WMI/注册表需第三方库封装需 syscall 层封装Linux 系统信息/proc、/sysfs 直接读方便方便异步并发模型async/await 成熟相对弱强团队维护成本有 C# 底子的团队容易接脚本编辑自由需要 Go 经验这个对比不是说 C# 一定比另外两门强而是在管理客户端这个场景里它两边都能占到便宜Windows 侧不需要绕一层 agentLinux 侧不需要自找麻烦。后面要拆的 fog-client大体上就按采集器、任务执行器、上报通道、本地调度四个模块组织职责边界非常清楚。2.2 从采集到上报四个模块的职责边界管理客户端最忌把所有逻辑混在 Main 方法里fog-client 的拆分方式值得参考。采集器只负责从系统层拿数据输出统一的 DeviceInfo 对象不关心数据怎么发出去任务执行器从队列里取服务端下发的任务区分 shell 命令、文件同步、定时快照等类型执行完写 TaskResult上报通道把序列化后的 JSON POST 到服务端管超时、重试和失败缓存本地调度用多个周期定时器驱动上面的三块心跳、全量上报、任务轮询各自用独立间隔。这个拆分带来的直接好处是你改采集逻辑时不用动上报代码换服务端协议时也不用碰采集器。比如服务端从 HTTP 改成 HTTPS只需要动 Reporter 和配置文件采集器一行不用改。任务执行器的本地队列还有个隐藏作用哪怕服务端异常下发了一百个任务客户端也会一条条消化而不是瞬时创建一百个进程把 CPU 打满相当于一个天然的背压阀。2.3 同一套源码跑两个平台条件编译与运行时判断跨平台在 C# 里靠两件事落地条件编译符号和运行时平台判断。条件编译解决“某些包只有特定平台有”的问题运行时判断解决“同一个代码入口按平台走不同实现”的问题。下面的代码是 fog-client 里很典型的平台分流写法解决“读 CPU 型号”这一个功能在 Windows 和 Linux 上各走一套实现// HardwareInfo.cs // 场景采集 CPU 型号Windows 走 WMILinux 走 /proc/cpuinfo using System.Runtime.InteropServices; public static class HardwareInfo { public static string GetCpuModel() { if (OperatingSystem.IsWindows()) { // Windows 平台用 ManagementObjectSearcher 查 Win32_Processor // 注意 System.Management 是 Windows-only 的 API需要 NuGet 包 using var searcher new System.Management.ManagementObjectSearcher( SELECT Name FROM Win32_Processor); foreach (var obj in searcher.Get()) { return obj[Name]?.ToString() ?? Unknown CPU; } } else if (OperatingSystem.IsLinux()) { // Linux 平台解析 /proc/cpuinfo取第一行 model name foreach (var line in File.ReadAllLines(/proc/cpuinfo)) { if (line.StartsWith(model name)) { return line.Split(:, 2)[1].Trim(); } } } return Unknown CPU; } }逻辑说明Windows 分支用 ManagementObjectSearcher 执行 WQL 查询返回第一个 CPU 的 NameLinux 分支逐行读 /proc/cpuinfo命中 model name 后按冒号切两段再 Trim两边结果语义一致。冒号拆分用 Split(:, 2) 只拆第一个冒号防止 CPU 型号文本里再出现冒号时被截断。参数说明OperatingSystem.IsWindows() 是 .NET 5 提供的方法旧项目可以用 RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 等价替代。System.Management 本身是 Windows-only 的包在 Linux 上编译能通过但一调就翻车所以平台判断必须先于 WMI 调用。条件编译的典型用法则是隔离引用差异#if NETFRAMEWORK // .NET Framework 下 System.Management 直接可用 #else // .NET 5 需要先引入 System.Management NuGet 包 #endif实际工程里我一般建议条件编译只包住引用层面的差异业务逻辑尽量走运行时判断。这样 Linux 包在发布前就能完整编译出来不会等到运行时才发现平台专属调用。3. 核心模块实战采集器、任务执行器与重试上报3.1 采集器机器标识与内存信息的两套来源采集器是整个客户端的入口第一优先要采集的是 MachineId。Windows 上常见做法是读注册表 Cryptography 项下的 MachineGuidLinux 上则是 systemd 维护的 /etc/machine-id。两者都是安装系统时生成的持久化 ID适合做设备主键。关键是要做好回退链因为精简系统和容器里文件不一定存在。// SysInfoCollector.cs —— 机器标识采集含回退链 public static string GetMachineId(string configuredSource auto) { if (configuredSource guid) return Guid.NewGuid().ToString(N); if (OperatingSystem.IsWindows()) { // Windows: 注册表 MachineGuid一般不会变化 using var key Microsoft.Win32.Registry.LocalMachine.OpenSubKey( SOFTWARE\Microsoft\Cryptography); var value key?.GetValue(MachineGuid)?.ToString(); if (!string.IsNullOrEmpty(value)) return value; } else if (OperatingSystem.IsLinux()) { // Linux: 优先 /etc/machine-id容器里往往不存在 var candidates new[] { /etc/machine-id, /var/lib/dbus/machine-id }; foreach (var path in candidates) { if (File.Exists(path)) return File.ReadAllText(path).Trim(); } } // 兜底用随机 GUID至少客户端不会因为取 ID 失败而启动崩溃 return Guid.NewGuid().ToString(N); }逻辑说明configuredSource 参数允许配置中心强制指定 ID 来源auto 模式按平台走默认链路。Linux 分支给了两个候选路径因为容器镜像或精简系统里 /etc/machine-id 可能不存在但 /var/lib/dbus/machine-id 还在。最后兜底用随机 GUID保住采集流程不中断。参数说明MachineIdSource 配置项推荐值有 auto、registry、file、guid。auto 最省事guid 模式适合不想暴露机器真实标识的离线场景。注意回退到随机 GUID 后重启会生成新 ID资产系统里这会造成设备重复。正确做法是把回退原因和最终 ID 一起上报由服务端判断是否合并。内存采集同样分两套来源注意 /proc/meminfo 单位是 kB需要转成 MB// 内存总量Windows 走 WMILinux 走 /proc/meminfo public static long GetTotalMemoryMb() { if (OperatingSystem.IsWindows()) { using var searcher new System.Management.ManagementObjectSearcher( SELECT TotalPhysicalMemory FROM Win32_ComputerSystem); foreach (var obj in searcher.Get()) { var value obj[TotalPhysicalMemory]?.ToString(); if (long.TryParse(value, out var bytes)) return bytes / 1024 / 1024; } } else if (OperatingSystem.IsLinux()) { foreach (var line in File.ReadAllLines(/proc/meminfo)) { if (line.StartsWith(MemTotal:)) { var parts line.Split(:, 2)[1].Trim() .Split( , StringSplitOptions.RemoveEmptyEntries); if (long.TryParse(parts[0], out var kb)) return kb / 1024; // meminfo 单位是 kB转成 MB } } } return 0; }为什么 Windows 总内存走 WMI 而不是 PerformanceCounterPerformanceCounter 按分类计数据部分精简服务器没有对应计数器类别WMI 的 Win32_ComputerSystem.TotalPhysicalMemory 对 Windows Server Core 同样可用兼容面更广。Split 用 RemoveEmptyEntries 是为了处理多空格间隔避免解析出空字符串。3.2 任务执行器队列、分发与超时控制服务端下发的任务到达客户端后先进入本地 ConcurrentQueue再由 Dispatcher 一条条消费。这样即使服务端短暂不可用已拉到的任务也不会丢同时限制并发度避免任务风暴打爆客户端。// TaskDispatcher.cs —— 任务队列与并发安全的消费 public class TaskDispatcher { private readonly ConcurrentQueueRemoteTask _queue new(); public void Enqueue(RemoteTask task) _queue.Enqueue(task); public async Task RunLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { if (_queue.TryDequeue(out var task)) { var result await ExecuteAsync(task, ct); // 结果回传由 Reporter 完成Dispatcher 不关心 HTTP 细节 await OnTaskCompleted(result, ct); } else { await Task.Delay(50, ct); } } } private async TaskTaskResult ExecuteAsync(RemoteTask task, CancellationToken ct) { var timeout TimeSpan.FromSeconds( task.TimeoutSeconds 0 ? task.TimeoutSeconds : 60); using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); try { return task.Type switch { shell await RunShellAsync(task.Payload, cts.Token), file_sync await FileSyncAsync(task.Payload, cts.Token), _ new TaskResult { Success false, Message $未知任务类型: {task.Type} } }; } catch (OperationCanceledException) { return new TaskResult { Success false, Message $任务执行超时{timeout.TotalSeconds}s }; } catch (Exception ex) { // 任何异常都要落回 TaskResult不能让 Dispatcher 崩溃 return new TaskResult { Success false, Message ex.Message }; } } }逻辑说明Enqueue 负责收任务RunLoopAsync 在没有取消时不断出队空队列时等 50ms 再轮询。每个任务用独立的 CancellationTokenSource 控制超时switch 表达式按 type 分发未知类型返回失败结果而不是抛异常保证执行器稳定。参数说明TimeoutSeconds 是任务 payload 里的字段默认 60 秒。CreateLinkedTokenSource 同时关联客户端停机信号和任务超时避免停机时任务还在后台跑。如果任务本身需要长时间执行超时值要由服务端配置而不是写死。跑 shell 命令时注意用 WaitForExitAsync 而不是阻塞式 WaitForExit否则 CancellationToken 没法真正中断命令// RunShellAsync 简化实现 private static async TaskTaskResult RunShellAsync(RemoteTaskPayload payload, CancellationToken ct) { var psi new ProcessStartInfo { FileName OperatingSystem.IsWindows() ? cmd.exe : /bin/bash, RedirectStandardOutput true, RedirectStandardError true, UseShellExecute false, CreateNoWindow true }; if (OperatingSystem.IsWindows()) psi.Arguments $/c \{payload.Command}\; else { psi.ArgumentList.Add(-c); psi.ArgumentList.Add(payload.Command); } using var process Process.Start(psi); var outputTask process.StandardOutput.ReadToEndAsync(); var errorTask process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(ct); await Task.WhenAll(outputTask, errorTask); return new TaskResult { Success process.ExitCode 0, Message outputTask.Result }; }关键点UseShellExecutefalse 是必须的它让 stdout 和 stderr 可以被重定向CreateNoWindowtrue 保证客户端在后台运行时不弹黑框。Linux 分支用 ArgumentList 而不是拼字符串减少命令注入和转义问题。3.3 上报通道JSON over HTTP、指数退避与失败落盘上报通道是客户端和服务端之间的唯一数据通路最怕网络抖动导致丢数据。fog-client 的上报用 POST JSON 加指数退避重试第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多三次就进入失败落盘而不是无限重试。这样既不过度消耗服务端资源也能在瞬断场景恢复。// Reporter.cs —— 带指数退避的设备上报 public class Reporter { private readonly HttpClient _httpClient; private readonly string _baseUrl; private readonly string _cacheDirectory; public async Taskbool ReportAsync(DeviceInfo payload, CancellationToken ct) { var maxAttempt 3; for (var attempt 1; attempt maxAttempt; attempt) { try { using var resp await _httpClient.PostAsJsonAsync( ${_baseUrl}/api/devices/report, payload, ct); if (resp.IsSuccessStatusCode) { return true; } // 非 2xx 也退避防止服务端被瞬时高负载压垮 await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); } catch (HttpRequestException) { if (attempt maxAttempt) break; // 网络瞬断按 1、2、4 秒退避 await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); } } // 三次都失败就落盘下个周期再补报 var cacheFile Path.Combine(_cacheDirectory, ${payload.MachineId}.json); await File.WriteAllTextAsync(cacheFile, JsonSerializer.Serialize(payload), ct); return false; } }逻辑说明PostAsJsonAsync 会把 payload 按 System.Text.Json 配置序列化后 POST返回 2xx 直接结束网络异常类型DNS 解析失败、连接超时、连接被重置走指数退避。最后失败会把 JSON 写进本地磁盘丢数据窗口从几秒降到几乎为零。参数说明两组退避时间分别对应服务端“能响应但不成功”和“根本连不上”两种场景。重试次数和间隔建议放配置中心比如 ReportRetryCount 和 ReportRetryBaseSeconds。如果内网断连超过 10 分钟只重试三次不够必须靠失败落盘兜底。3.4 本地调度心跳、全量上报与任务轮询的三个节奏fog-client 有三条独立的节拍心跳每 5 分钟一次数据量极小只上报机器在线的状态和客户端版本全量上报每 30 分钟一次把采集到的完整信息发出去任务轮询每 15 秒一次因为任务执行对实时性有要求这个间隔要短。三个周期用同一个 CancellationToken停机时全部循环一起退出。用 PeriodicTimer 实现比 Task.Delay 好因为 PeriodicTimer 会在下一个 tick 重新计时不会累计漂移// Scheduler.cs —— 三个独立周期 public class Scheduler : IAsyncDisposable { private readonly PeriodicTimer _heartbeatTimer new(TimeSpan.FromMinutes(5)); private readonly PeriodicTimer _reportTimer new(TimeSpan.FromMinutes(30)); private readonly PeriodicTimer _taskTimer new(TimeSpan.FromSeconds(15)); public async Task RunAsync(CancellationToken ct) { var tasks new[] { RunHeartbeatLoopAsync(ct), RunReportLoopAsync(ct), RunTaskPollLoopAsync(ct) }; await Task.WhenAll(tasks); } private async Task RunHeartbeatLoopAsync(CancellationToken ct) { while (await _heartbeatTimer.WaitForNextTickAsync(ct)) { await _reporter.ReportHeartbeatAsync(ct); } } }要点PeriodicTimer 能感知执行时长。某一次上报卡了 40 秒下一个 tick 不会立刻触发而是等满 5 分钟再说天然错峰。任务轮询这 15 秒间隔要结合服务端并发量调轮询百台机器可以轮询上万台就要考虑长连接或消息推送不然服务端容易被每秒几百个空请求打满。4. 部署与配置从发布命令到服务托管4.1 发布命令RID、自包含与单文件把 fog-client 推到目标机最常见方式是 dotnet publish。两个典型命令一组分别产出 Windows 和 Linux 的部署包# Windows 10/11、Windows Server 2019 的 x64 机器 dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue -o ./dist/win-x64 # 常见 Linux 发行版CentOS 7、Ubuntu 18.04的 x64 机器 dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFiletrue -o ./dist/linux-x64参数说明-r 指定 RuntimeIdentifier管理客户端建议直接用 win-x64 和 linux-x64不用 x86 兼容畸形架构。如果目标机是 ARM 盒子把 -r 换成 linux-arm64。--self-contained true 把 .NET 运行时打进发布目录目标机零依赖代价是体积多 30-50 MB。PublishSingleFiletrue 把所有托管 DLL 并进单个 exe方便脚本分发。我一般还会显式关掉裁剪避免 WMI 和反射类操作被剪出问题dotnet publish -c Release -r linux-x64 --self-contained true \ -p:PublishSingleFiletrue -p:PublishTrimmedfalsePublishTrimmedfalse 是给使用 WMI 或反射代码的客户端留的一条后路。裁剪模式可能剪掉 Win32_Processor 查询所需的内部类型发布包多几 MB换稳定性值得。如果确实要裁剪先跑一遍第 6 章的 mock 三连测再发。4.2 配置文件服务端地址与采集参数fog-client 的配置集中在单个 appsettings.json结构设计得很直接{ Server: { BaseUrl: http://ops-cfg-server:8080, ReportIntervalMinutes: 30, HeartbeatIntervalMinutes: 5, TaskPollingIntervalSeconds: 15, ReportRetryCount: 3, UseSystemProxy: false }, Client: { MachineIdSource: auto, CacheDirectory: cache, LogLevel: Information, TaskTimeoutSeconds: 60 } }配置项默认/推荐说明Server.BaseUrl必填服务端根地址不带尾部斜杠ReportIntervalMinutes30全量上报周期HeartbeatIntervalMinutes5心跳周期TaskPollingIntervalSeconds15任务轮询周期值越小任务越及时服务端压力越大ReportRetryCount3上报失败重试上限UseSystemProxyfalse内网环境默认关闭系统代理避免请求绕一圈注意 BaseUrl 末尾不要加斜杠否则拼接 /api/devices/report 时会出现 //api不少网关会直接 404。代码里用 ConfigurationBuilder 加载并绑定强类型// AppConfig.cs —— 配置加载与强类型绑定 var configuration new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile(appsettings.json, optional: false, reloadOnChange: true) .Build(); var serverConfig configuration.GetSection(Server).GetServerOptions();reloadOnChange 设为 true 后服务端参数调整客户端不需要重启进程对资产管理场景很关键毕竟几十台客户端不可能一台台手动重启。改完配置源新值会在下个轮询周期生效。4.3 服务托管Windows 用 scLinux 用 systemd客户端必须开机自启不能靠用户手动双击。Windows 上最直接的服务注册命令sc create fog-client binPath C:\Program Files\FogClient\fog-client.exe start auto这条命令有个经典坑binPath 后面的空格是必须的sc 的语法要求在等号后有一个空格否则报语法错误。binPath 必须指向完整可执行文件路径不能写相对路径路径里有空格的整段用双引号包住。启动和查询sc start fog-client sc query fog-clientLinux 侧就是 systemd service 文件# /etc/systemd/system/fog-client.service [Unit] DescriptionFog Client Afternetwork-online.target Wantsnetwork-online.target [Service] WorkingDirectory/opt/fog-client ExecStart/opt/fog-client/fog-client Restartalways RestartSec10 EnvironmentDOTNET_SYSTEM_GLOBALIZATION_INVARIANT1 [Install] WantedBymulti-user.targetDOTNET_SYSTEM_GLOBALIZATION_INVARIANT1 用于精简系统没有 ICU 库的场景否则客户端启动时会因为找不到文化信息抛异常。如果客户端日志需要完整本地化输出这个变量不要加让 .NET 走默认的全球化模式。随后执行sudo systemctl daemon-reload sudo systemctl enable --now fog-client systemctl status fog-clientAfternetwork-online.target 和 Wantsnetwork-online.target 的组合要保留防止客户端在网卡还没就绪时启动把 BaseUrl 的 DNS 解析失败当成不可恢复错误。4.4 网络前置条件先确认四项再分发服务端地址配置完成后在目标机上先做一次连通性验证不要部署几十台再回头排查。curl -v http://ops-cfg-server:8080/api/devices/ping四项检查按顺序做一是目标机能不能解析服务端域名用 getent hosts 或 nslookup二是防火墙是否放行出站ping 通不代表 TCP 层通用 telnet IP 8080 验证三是是否需要系统代理如果需要就打开 UseSystemProxy四是服务端是否允许该网段来源收到 403 或 401 就让服务端查白名单。这四项过了再批量推包比事后救火省心得多。5. 避坑指南五个让客户端半夜挂掉的真实场景5.1 WMI 首次查询慢采集器被误判为卡死现象客户端刚部署完日志显示第一次采集 CPU 信息耗时 8-12 秒业务侧误判为客户端卡死超时后重试导致采集队列堆叠。原因WMI 服务Winmgmt是懒启动的首次 WQL 查询会触发 WMI 仓库初始化在 Windows Server Core 上更明显耗时能到十几秒。解决采集逻辑里给 ManagementObjectSearcher 设置检索超时超过 15 秒就放弃这次采集并返回空值。更重要的是超时后不要立即重试让下个周期自然补采。fog-client 的采集器按模块各自独立 try-catch单个采集点失败不影响其他项。5.2 /etc/machine-id 不存在客户端开机即崩现象容器化部署或精简 Linux 系统上客户端启动第一条日志就是 FileNotFoundException指向 /etc/machine-id。原因容器镜像默认不带 machine-id部分精简系统把 dbus 相关文件也裁掉了而代码里直接 File.ReadAllText 读固定路径。解决采集 MachineId 时按候选路径探测/etc/machine-id、/var/lib/dbus/machine-id 依次尝试都没有就回退到 hostname 加启动时间生成的稳定键同时打 warning 日志。回退时不要直接用随机 GUID否则每次重启设备在服务端都会生成一个新资产。5.3 sc create 等号后没有空格服务建不出来现象sc create 命令在 Windows 上报无效参数或者服务创建成功但 start auto 没生效服务一启动就退出。原因sc 命令的语法是 name value等号后必须有一个空格。脚本里写成 binPath 不带空格时系统解析会把路径参数吞掉服务永远起不来。解决脚本里固定写成 binPath C:... start auto等号后保留空格。这是 Windows 服务命令里最容易踩的坑没有之一。建议写成一个 .bat 模板让所有部署人员直接复制不要靠手敲。5.4 System.Management 在 Linux 上能编译运行时才翻车现象把同一份源码在 Linux 上 publish 成功但跑到采集步骤抛 System.PlatformNotSupportedException调用栈指向 ManagementObjectSearcher。原因System.Management 是 Windows-only APINuGet 包在非 Windows 平台没有实际实现。编译期不报错是因为 TFM 声明支持运行时才暴露真实平台限制。解决所有 WMI 调用必须用 OperatingSystem.IsWindows() 包起来Linux 分支永远走 /proc 和 /sys。发布流程里加一道双平台 smoke test先 Linux 编译跑一次采集再 Windows 编译跑一次任何一边崩都直接拦截。5.5 HttpClient 按请求创建内网 socket 被拖垮现象客户端跑了一周任务执行越来越慢netstat 看到大量 TIME_WAIT 连接日志出现 SocketException: Too many open files。原因Reporter 里每次上报都 new HttpClient()HttpClient 底层的 socket 不会随请求结束立刻释放加上内网代理连接积压把端口耗尽。解决HttpClient 做成单例或者用 IHttpClientFactory 管理生命周期。注意单例后 DNS 变更不会自动生效配合 PooledConnectionLifetime 设置一个连接复用时长比如 5 分钟既能复用端口又能看到 DNS 更新。这属于上线前就该定下来的底层设计真等到 socket 耗尽再改客户端得全部重发一遍。6. 进阶验证用本地 Mock 服务端做三连测6.1 Mock 服务端起在本地把三测跑通部署任何管理客户端我最怕的是代码逻辑正确但服务端对不上。所以 fog-client 工程下来后先起一个本地 Mock 服务端把上报、心跳、任务回执三件事验完再考虑真实环境。用 ASP.NET Core Minimal API 五十行内就能完成// MockServer/Program.cs —— 临时验证用不用于生产 using System.Collections.Concurrent; var builder WebApplication.CreateBuilder(args); var app builder.Build(); var received new ConcurrentBagDeviceInfo(); app.MapPost(/api/devices/report, (DeviceInfo device) { received.Add(device); return Results.Ok(new { code 0 }); }); app.MapPost(/api/devices/heartbeat, (HeartbeatInfo hb) { return Results.Ok(new { code 0, serverTime DateTime.UtcNow }); }); app.MapPost(/api/tasks/poll, () { // 固定下发一条 shell 任务用于验证任务执行链路 return Results.Ok(new { task new { id Guid.NewGuid().ToString(), type shell, payload new { command echo ok } } }); }); app.MapGet(/api/devices/received, () received.ToArray()); app.Run(); public record DeviceInfo(string MachineId, string CpuModel, long MemoryMb); public record HeartbeatInfo(string MachineId, string Version);运行起来后把目标机的 BaseUrl 指向本地地址dotnet run --project MockServer curl http://localhost:5225/api/devices/heartbeat然后启动 fog-client。第一测是上报等服务端日志打到 report 请求验证 DeviceInfo 的 JSON 字段名大小写和序列化配置一致第二测是心跳测试时把 HeartbeatIntervalMinutes 临时改成 1 分钟不用真等五分钟第三测是任务回执Mock 的 poll 接口返回一条 echo 命令客户端执行后应回传 TaskResult再补一个 MapPost /api/tasks/result 就能验证结果。6.2 两个高频返工点字段命名与自签证书校验三连测最容易暴露的是字段命名问题。客户端序列化配置用了 camelCase 策略时上报字段是 machineId、cpuModel而服务端如果按 PascalCase 绑字典就匹配不上。Mock 服务端用 record 类接收时 ASP.NET Core 默认大小写不敏感很容易掩盖这个坑。正确的验证方式是把 Mock 收到的原始 JSON 原样打印出来看一次而不是依赖模型绑定后的值。另一个高频返工点是自签证书。内网服务端一般用 HTTPS 加自签证书客户端 HttpClient 默认校验严格直接连会抛证书错误。稳妥做法是在 HttpClientHandler 的 ServerCertificateCustomValidationCallback 里只放行配置过的证书指纹不要全局忽略校验。测试阶段可以临时放开生产环境必须回到白名单校验否则等于把半条命交给中间人。从那以后我每次改采集逻辑或序列化配置都强制走一遍这套流程本地 Mock 起服务、改 BaseUrl、跑三连测、确认原始 JSON全绿才执行发布命令。这个习惯帮我挡住了至少三次字段对不上和 WMI 超时引发的线上问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?