简介:Star Rat 3.1源码升级版是一套面向Windows平台的远程控制软件源代码,适合需要学习远控通信、系统兼容与客户端定制开发的中高级开发者,也可用于技术支持、系统管理等场景的原型研究。压缩包共4558个文件,约22.39MB,以png界面素材、h与cpp核心源码、rc资源脚本及ico/bmp图标位图为主,并包含sln、vcproj等工程文件,项目结构较完整,便于在Visual Studio中梳理模块、编译调试和对照资源组织方式。现有451人学习,说明其对远控源码学习者具有一定参考价值。源码覆盖Windows 2000至Win8.1等多个版本,完整工程便于研究连接管理、身份验证、数据传输与多并发处理思路,也可按需调整界面、文件传输或屏幕共享等功能,适合作为理解远控软件架构、进行二次开发和课程实践的综合素材,并可在实际项目中验证与扩展。
1. 先看清 Star Rat 3.1 是套什么代码:一个 C/S 远程设备管理台
有个做内网运维的朋友拿到一份Star Rat 3.1 源码升级版,双击解决方案编译,报了一屏的未能找到类型或命名空间,界面能起来,客户端却连不上,日志里空空如也。这类项目天生不友好——它是个 C/S 架构的远程设备管理台,服务端是 WinForms 控制界面加监听端口,客户端常驻被管机器,两边靠自定义二进制协议通信,功能覆盖会话管理、命令行下发、屏幕回传、文件浏览。它适合谁?手里有一批自己的机器要集中看状态、批量执行命令、远程看屏的运维和桌面支持人员,以及想学一套完整 Socket 长连接工程怎么搭的 .NET 开发者。前提说清楚:只用在自己有管理权的设备上,别人机器上一个字节都别碰。
2. 工程结构与技术选型:Server/Client/Common 三工程怎么拆
2.1 解决方案目录:三个工程各自的边界
拆这种项目,先看 .sln 里挂了几个 csproj,比读一百行代码都快。Star Rat 3.1 的骨架是典型三段式:Common放协议和日志,Server放监听和界面,Client放被管端逻辑。这么拆的理由很实际——协议结构体两边都要用,复制粘贴两份迟早改不同步,一个字段顺序变了就是线上事故。
StarRat.sln ├── StarRat.Common/ # 三端共享:包结构、拆包器、日志 │ ├── Packet.cs # 包类型枚举 + 包头定义 │ ├── Framer.cs # 粘包拆包 │ ├── Log.cs # 简易文件日志 │ └── StarRat.Common.csproj ├── StarRat.Server/ # 控制端:WinForms │ ├── Forms/MainForm.cs # 主界面,会话列表 + 操作面板 │ ├── Net/Listener.cs # TcpListener 封装,负责 accept │ ├── Net/SessionManager.cs # 会话字典,uid -> Session │ ├── Data/Repository.cs # SQLite 读写 │ └── StarRat.Server.csproj ├── StarRat.Client/ # 被管端:托盘常驻 │ ├── Program.cs # 单实例 + 托盘启动 │ ├── Net/ServerConnection.cs# 重连 + 心跳 │ ├── Handlers/ # 每类指令一个 Handler │ └── StarRat.Client.csproj └── StarRat.Plugin/ # 可选:扩展指令的独立程序集这个结构里最值得学的是Handlers/目录。指令处理不是塞在switch里的一坨case,而是一个IPacketHandler接口加若干实现类,靠字典注册。加新功能时只动两处:PacketType加枚举值、Handlers加一个类。改到第三个月你会感谢当初这个决定——血泪经验,switch涨到 40 个case的时候,谁都不敢删任何一行。
Plugin工程是这个"升级版"相对老版本多出来的东西。它不是必需项,作用是让指令处理可以编译成独立 dll,运行时扫目录加载。用反射加载程序集,代价是启动慢个一两百毫秒,收益是不用为了加一条自定义指令重新发布整个客户端。
2.2 为什么自己写二进制协议而不上 HTTP
很多人第一反应是:都 2020 年代了,为什么不用 gRPC 或者直接 REST?放到这个场景里答案很直接——屏幕帧是高频小包,命令回显是流式的,HTTP 每次都带一堆 header,在弱网下连接复用还容易出玄学问题。自己定协议,包头 8 字节,剩下全是载荷,开销可控。
协议设计上,Star Rat 3.1 用的是「魔数 + 类型 + 长度」的定长包头:
// Common/Packet.cs public enum PacketType : ushort { Heartbeat = 0x0001, // 心跳,载荷是发送端 TickCount SysInfo = 0x0002, // 上线时上报主机名/系统版本 ShellCmd = 0x0010, // 下发命令行 ShellResult = 0x0011, // 命令回显,可能分多包 ScreenStart = 0x0020, // 请求开始推屏 ScreenFrame = 0x0021, // 一帧图像数据 FileList = 0x0030, // 列目录 FileDownload = 0x0031 // 拉取文件 } public class Packet { public PacketType Type { get; set; } public byte[] Payload { get; set; } // 序列化到输出流:4字节魔数 + 2字节类型 + 4字节长度 + 载荷 public void WriteTo(Stream stream) { var header = new byte[10]; BitConverter.GetBytes(0x53545231).CopyTo(header, 0); // "STR1" BitConverter.GetBytes((ushort)Type).CopyTo(header, 4); BitConverter.GetBytes(Payload.Length).CopyTo(header, 6); // 注意是 int,不是 ushort stream.Write(header, 0, header.Length); stream.Write(Payload, 0, Payload.Length); } }WriteTo里那个魔数0x53545231转成 ASCII 正好是STR1,作用是在收到垃圾数据时能快速判定"这不是我的包",而不是硬解析出一堆乱码把内存撑爆。长度字段用int而不是ushort,是这套代码里少数做对了的地方——老版本用ushort,屏幕帧一大就截断,画面下半截永远是花的。
Payload内部一般再套一层:命令回显用 UTF-8 文本直接扔进去,结构化数据(比如文件列表)用 JSON 序列化后转字节。全部上 Protobuf 也行,但会增加一个依赖,对这个体量的项目属于过度设计。我一般会在Common里放两个扩展方法ToPayload<T>()/FromPayload<T>(),把JsonSerializer藏起来,业务代码看着干净。
2.3 落盘与配置:SQLite 表结构和 runtime.json
控制端重启后会话记录不能丢,Star Rat 3.1 用的是 SQLite,System.Data.SQLite或者Microsoft.Data.Sqlite都行,后者依赖少一点。表结构不复杂,核心就两张:
-- 被管设备档案,uid 是客户端首次启动生成的 GUID,换 IP 也不影响识别 CREATE TABLE clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, hostname TEXT, os_version TEXT, last_ip TEXT, last_seen INTEGER, -- Unix 秒,用来判断多久没上线 remark TEXT -- 人工备注,比如"三楼会议室机器" ); -- 指令下发记录,出问题时靠这张表回溯 CREATE TABLE commands ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_uid TEXT NOT NULL, packet_type INTEGER NOT NULL, body TEXT, created_at INTEGER NOT NULL, finished_at INTEGER, status INTEGER NOT NULL DEFAULT 0 -- 0待发 1已发 2已完成 3超时 ); CREATE INDEX idx_commands_uid ON commands(client_uid, created_at DESC);clients.uid用 GUID 而不是 IP 做主键,这个设计很关键。DHCP 环境下机器换 IP 是常事,用 IP 当身份,第二天整个列表全是陌生条目。commands表的意义在于:用户点了个"执行",界面转圈转了十秒没反应,到底是包没发出去、发出去了没回、还是回来了渲染卡住?有这张表,至少能分清前两种。
运行参数外置到一个runtime.json,别硬编码在代码里:
{ "Listen": { "Port": 7801, "Backlog": 128, "MaxSessions": 200 }, "Session": { "HeartbeatIntervalMs": 5000, "IdleTimeoutMs": 20000, "RetryDelayMs": 3000 }, "Screen": { "MaxWidth": 1280, "JpegQuality": 55, "FrameIntervalMs": 200 } }IdleTimeoutMs必须是HeartbeatIntervalMs的三到四倍,留够丢一两个包的余量。这两个值调成一样,弱网下就会周期性掉线,而且掉得毫无规律,排查起来纯靠猜。
3. 核心链路拆解:一条指令从下发到回显的完整路径
3.1 握手与心跳:会话是怎么被"养"住的
TCP 连接建立后,客户端第一件事是发SysInfo包上报主机名和系统版本,服务端收到后生成或更新clients记录,把uid和当前TcpClient绑进SessionManager的字典。这个字典的 key 用uid,value 是会话对象,里面装着NetworkStream、最后活跃时间、发送队列。
// Server/Net/SessionManager.cs private readonly ConcurrentDictionary<string, Session> _sessions = new(); public void Bind(string uid, Session session) { // 同一台机器重连时,先把旧连接顶掉,否则会出现两个会话抢同一个 uid if (_sessions.TryRemove(uid, out var old)) { Log.Warn($"uid={uid} 重复上线,关闭旧会话 {old.RemoteEndPoint}"); old.Dispose(); } _sessions[uid] = session; } public IEnumerable<Session> Snapshot() => _sessions.Values.ToList();ConcurrentDictionary而不是普通Dictionary加锁,因为 accept 线程、心跳巡检线程、UI 线程都会碰这个集合。用Snapshot()返回副本,是为了避免 UI 在遍历的时候另一个线程往里加会话,抛Collection was modified——这种异常在 Release 下偶发,在 Debug 下几乎必现,属于典型的环境差异坑。
心跳是客户端的定时任务,Task.Delay循环,别用System.Timers.Timer:
// Client/Net/HeartbeatService.cs private async Task LoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { var pkt = new Packet { Type = PacketType.Heartbeat, Payload = BitConverter.GetBytes(Environment.TickCount) }; await _connection.SendAsync(pkt, token); } catch (Exception ex) { Log.Warn($"心跳发送失败:{ex.Message}"); await _connection.ReconnectAsync(token); // 失败就重连,别让循环退出 } await Task.Delay(_intervalMs, token); } }try/catch包在循环内部而不是外部,是为了让一次发送失败不影响后续心跳。SendAsync内部对NetworkStream的写操作必须串行化,用一个SemaphoreSlim(1,1)守着,否则心跳包和屏幕帧同时写同一个流,字节交错,接收端拆包直接乱套。这个问题在单机测试时基本不出现,一上真实网络并发就炸。
服务端侧对应一个巡检任务,每 5 秒扫一遍Snapshot(),把Now - LastActive > IdleTimeoutMs的会话关掉并标记离线。
3.2 粘包拆包:包头长度算错就全乱
TCP 是字节流,一次Read拿到的可能是半个包、一个包、或者三个包粘一起。拆包器是这套代码里最容易写错、也最难排查的模块,错了之后现象是"偶尔丢一条命令"或者"界面卡死",没有任何异常栈,纯黑匣子。
// Common/Framer.cs public class Framer { private const int HeaderSize = 10; // 魔数4 + 类型2 + 长度4 private const uint Magic = 0x53545231; private readonly List<byte> _buffer = new(); public IEnumerable<Packet> Feed(byte[] chunk, int count) { for (int i = 0; i < count; i++) _buffer.Add(chunk[i]); while (true) { if (_buffer.Count < HeaderSize) yield break; uint magic = BitConverter.ToUInt32(_buffer.ToArray(), 0); if (magic != Magic) { Log.Error("魔数不匹配,清空缓冲区重新同步"); _buffer.Clear(); yield break; } int len = BitConverter.ToInt32(_buffer.ToArray(), 6); if (len < 0 || len > 8 * 1024 * 1024) // 上限保护,防止被撑爆 { Log.Error($"载荷长度异常 {len},丢弃缓冲"); _buffer.Clear(); yield break; } if (_buffer.Count < HeaderSize + len) yield break; // 还没收全,等下一批 var payload = _buffer.GetRange(HeaderSize, len).ToArray(); var type = (PacketType)BitConverter.ToUInt16(_buffer.ToArray(), 4); _buffer.RemoveRange(0, HeaderSize + len); yield return new Packet { Type = type, Payload = payload }; } } }逐段说。_buffer用List<byte>是为了方便拼接,数据量大时换成环形缓冲区或者MemoryStream更合适,但这份代码的量级够用。yield break表示"当前数据不够,先返回",注意不能用return,否则下次调用会从头开始,缓冲区直接废掉。
魔数校验失败时清空缓冲区而不是跳过一字节继续找,是权衡后的选择:正常情况下不该出现魔数错,出现了说明流已经脏了,慢慢滑动窗口找下一个STR1反而可能撞上载荷里的巧合字节。清空重来,让客户端重连,简单粗暴但有效。
长度上限 8MB 这道防线必须有。没有它,一个被篡改的长度字段就能让服务端new byte[2GB],直接 OOM 退出。这类"输入长度不可信"的处理,在任何自定义协议里都是标配。
BitConverter.ToUInt32(_buffer.ToArray(), 0)每次都ToArray(),性能上有浪费,改造成本不高,想优化就换成CollectionsMarshal.AsSpan或者直接维护一个byte[]加偏移量。
3.3 屏幕帧采集与传输的降码率手段
屏幕回传是带宽消耗大户。Star Rat 3.1 的做法是 GDI+ 截屏 + JPEG 压缩 + 限帧率,简单但够用。
// Client/Handlers/ScreenHandler.cs private byte[] CaptureFrame(int maxWidth, long quality) { var bounds = Screen.PrimaryScreen.Bounds; int w = Math.Min(bounds.Width, maxWidth); int h = (int)(bounds.Height * (w / (double)bounds.Width)); // 等比缩放 using var full = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb); using (var g = Graphics.FromImage(full)) { g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy); } using var scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb); using (var g = Graphics.FromImage(scaled)) { g.InterpolationMode = InterpolationMode.Bilinear; g.DrawImage(full, 0, 0, w, h); } // 编码成 JPEG,质量 0-100,55 左右在 1280 宽下大概 40-80KB 一帧 var codec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg"); using var eps = new EncoderParameters(1); eps.Param[0] = new EncoderParameter(Encoder.Quality, quality); using var ms = new MemoryStream(); scaled.Save(ms, codec, eps); return ms.ToArray(); }CopyPixelOperation.SourceCopy这个参数别省,默认行为在某些显卡驱动下会带上窗口阴影,截出来的图边缘发灰。缩放用Bilinear而不是HighQualityBicubic,后者在 200ms 一帧的频率下 CPU 占用明显偏高,画质提升肉眼几乎看不出来。
真正省带宽的不是压缩率,是「变化检测」。连续两帧做一次逐块哈希对比,没变化的块不传,只传变化区域加坐标。这功能在 3.1 里是可选项,配置文件里开Screen.DiffEnabled。开了之后静态桌面下流量能降一个数量级,代价是实现复杂度上一个台阶——接收端要维护上一帧的位图做拼合,任何一帧丢了后面全花。所以我一般建议先用固定帧率跑通,再考虑上差分。
4. 跑起来:从编译报错到第一次连通
4.1 环境准备:TargetFramework 和那几个必装组件
Star Rat 3.1 的 csproj 目标是.NET Framework 4.7.2,用 Visual Studio 2019 或 2022 打开都行,装「.NET 桌面开发」工作负载就够了。如果坚持用命令行编译,MSBuild 路径按自己机器改:
# 先还原 NuGet 包,别跳过这步,跳过必报找不到类型 nuget restore StarRat.sln # 编译整个解决方案,/m 开多进程加速 msbuild StarRat.sln /p:Configuration=Release /p:Platform="Any CPU" /m # 产物分别在两个目录,别在一个目录里找 # StarRat.Server/bin/Release/StarRat.Server.exe # StarRat.Client/bin/Release/StarRat.Client.exe如果编译报未能找到类型或命名空间名"Newtonsoft",八成是包没还原,或者 csproj 里的HintPath指向了原作者机器上的绝对路径(C:\Users\xxx\packages\...)。后者是这类"源码升级版"最常见的翻车点,原作者打包时没清引用路径,你得手动改成相对路径..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll,或者干脆删掉引用,改成 PackageReference 让 NuGet 自己管。
客户端要正常干活,运行账户需要有相应权限:列目录、执行命令、截屏,普通用户权限能做大部分事,但涉及系统目录和服务操作就得提权。部署时按最小权限原则来,别图省事全员管理员。
4.2 改配置:监听端口、连接串、授权白名单
服务端第一次启动大概率是这个状态:界面起来了,端口监听失败。原因通常是 7801 被占或者被防火墙拦了。先查占用:
# Windows 下看端口被谁占了 netstat -ano | findstr :7801 # 拿到 PID 后查进程 tasklist /FI "PID eq 12345"确认端口空出来后,改StarRat.Server/bin/Release/runtime.json里的Listen.Port。客户端那边对应改ClientConfig.json:
{ "Server": { "Host": "192.168.10.20", "Port": 7801, "ReconnectIntervalMs": 3000, "MaxReconnectAttempts": 0 }, "Identity": { "Uid": "", "GroupTag": "office-floor-3" }, "Feature": { "AllowShell": true, "AllowScreen": true, "AllowFileTransfer": false } }Identity.Uid留空表示首次启动自动生成 GUID 并写回文件,别手动填,填重复了两台机器会互相顶掉会话。MaxReconnectAttempts设 0 表示无限重连,适合常驻场景;调试阶段建议设成 5,免得日志被重连记录刷满。Feature那几个开关是运行时能力限制,关掉AllowFileTransfer之后对应指令包会被直接拒绝,比在服务端界面灰掉按钮更可靠——界面限制只是不让你点,协议层限制才是真的关上了。
服务端建议加一层来源白名单,只接受指定网段连入:
// Server/Net/Listener.cs private bool IsAllowed(IPEndPoint ep) { // 白名单从 runtime.json 读,格式 "192.168.10.0/24" return _allowList.Any(cidr => CidrMatch(ep.Address, cidr)); }这不是可选项。管理端监听在一个开放端口上却不做来源限制,等于把设备管理入口摊在网络上,出问题只是时间早晚。
4.3 验证:日志从哪看,怎么确认指令真的到了
编译通过、配置改完,启动顺序是服务端先起、客户端后起。验证连通别只看界面上的绿点,那个点只代表 TCP 连着,不代表协议通。按这个顺序查:
第一步,服务端日志里应该有accept from 192.168.10.35:51234,紧接着一条bind uid=xxxx。只有 accept 没有 bind,说明客户端连上了但SysInfo包没发出来或者发出来格式不对,问题在客户端。
第二步,客户端日志里应该有connected、heartbeat sent交替出现。心跳记录断了,要么是断线重连在循环,要么是Task.Delay被异常打断,去看 catch 里打出来的异常信息。
第三步,手动下发一条最简单的命令,比如whoami,然后查commands表:
-- 看最近 20 条指令的状态流转 SELECT id, client_uid, packet_type, status, datetime(created_at, 'unixepoch', 'localtime') AS t FROM commands ORDER BY id DESC LIMIT 20;status停在 0 说明包压根没发出去,问题在发送队列或会话状态;停在 1 说明发出去了没等到回,问题在接收端处理逻辑或者回包被拆包器丢了;跳到 2 但界面没显示,那就是 UI 线程更新出了问题,跟网络无关。这三条分支一分,排查范围立刻缩小到三分之一,比对着代码猜快得多。
5. 避坑与排查:5 条真实踩过的坑
5.1 坑一:编译报找不到 Newtonsoft,改了路径还是报
现象:nuget restore跑完,msbuild还是报未能找到类型或命名空间名"Newtonsoft",去packages目录看,dll 明明躺在那里。
原因:csproj 里的HintPath是原作者机器上的绝对路径,nuget restore只会下载包到本地packages目录,不会修正 csproj 里的引用路径。所以你看到的 dll 是新的,编译时找的是旧的。
解决:把<HintPath>里的绝对路径整体替换成相对路径..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll,或者更彻底一点,删掉<Reference>整块,改成<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />,让 NuGet 自己管。后一种做法在多人协作时更省心,代价是老版本的 VS 可能不支持。
5.2 坑二:客户端连上就掉,30 秒准掉一次
现象:连接建立成功,界面上能看到设备,但每次都是刚操作两下就掉线,重连又正常,周而复始,时间间隔很规律。
原因:两个可能,一是客户端心跳间隔HeartbeatIntervalMs大于等于服务端IdleTimeoutMs,服务端巡检先动手把连接关了;二是 WinForms 的 UI 线程被阻塞,async方法里混进了.Result或者.Wait(),导致心跳线程卡在同步等待上发不出去。
解决:先把IdleTimeoutMs调到心跳间隔的 4 倍,观察是否还掉。如果还掉,就把网络层的所有async调用链从头到尾捋一遍,搜.Result、.Wait()、GetAwaiter().GetResult(),全换成await。UI 线程里永远不碰网络 IO,所有网络操作扔到Task.Run或者直接在async void事件处理器里await到底。
5.3 坑三:屏幕画面卡住一半,下半截永远是花的
现象:推屏功能开启后,画面上半部分正常,下半部分永远停留在某一帧,或者干脆是灰色噪点。
原因:包头里的长度字段用了ushort,最大只能表示 65535 字节。1080P 分辨率下 JPEG 一帧轻松超过这个数,写入时BitConverter.GetBytes((ushort)Payload.Length)直接截断,接收端按错误的长度切包,后半截数据全部错位。
解决:长度字段改成int(4 字节),包头从 8 字节扩到 10 字节,两端的HeaderSize常量同步改。改完记得清掉旧版本客户端,新旧协议不兼容,混着跑会互相解出乱码。更好的做法是包头里加一个版本号字节,服务端收到不认识的版本直接拒绝并回一条升级提示,避免这种"静默错乱"。
5.4 坑四:高分屏下截屏坐标错位,只截到左上角一块
现象:在被管机器上开 150% 缩放,截出来的图只有屏幕左上角一小块内容,其它区域是黑的或者重复的,鼠标位置和实际画面完全对不上。
原因:进程没有声明 DPI 感知,Windows 会给它一个虚拟化的坐标系。Screen.PrimaryScreen.Bounds返回的是缩放后的逻辑像素,而CopyFromScreen按物理像素去抓,两者对不上,抓出来的区域自然不对。
解决:给客户端加一个app.manifest,声明 DPI 感知级别:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2</dpiAwareness> </windowsSettings> </application>加完之后Bounds返回的就是物理像素,截屏和鼠标坐标才对得上。这个坑恶心在于开发机都是 100% 缩放,测不出来,一到用户机器就翻车。
5.5 坑五:客户端被安全软件拦下,进程起不来
现象:双击客户端 exe 没反应,任务管理器里出现一秒就消失,Windows 事件日志里能看到应用被终止的记录。
原因:未签名的可执行文件加网络监听行为,很容易触发终端安全软件的拦截规则。这不是代码 bug,是发布流程缺失。
解决:走正规渠道——给客户端 exe 申请代码签名证书做签名,同时在内部 IT 的安全策略里把这个程序加进信任清单,报备用途和部署范围。自己机器上调试时,把开发目录加进排除项即可。千万别想着靠改名、加壳、混淆去绕过检测,那是把运维工具做成恶意软件的路子,只会让后续维护彻底不可控,也把项目带进不该去的地方。
6. 二次开发:把 Star Rat 3.1 改成你自己运维场景的工具
真正落地的时候,通用指令集总是不够用。比如你要批量查一批机器的磁盘剩余空间,现成的ShellCmd能跑wmic,但返回的是一坨文本,还得自己解析。这时候加一条专用指令比解析文本靠谱得多,改动量也就三十行。
先在Common/PacketType.cs里加枚举值,注意别和已有值撞号:
public enum PacketType : ushort { // ... 已有值保持不变 DiskInfo = 0x0040, // 请求磁盘信息 DiskInfoResult = 0x0041 // 返回 JSON 数组 }然后在客户端Handlers/下加一个处理器,实现统一接口:
// Client/Handlers/DiskInfoHandler.cs public class DiskInfoHandler : IPacketHandler { public PacketType HandledType => PacketType.DiskInfo; public Task<Packet> HandleAsync(Packet request, CancellationToken token) { var list = DriveInfo.GetDrives() .Where(d => d.IsReady && d.DriveType == DriveType.Fixed) .Select(d => new { Name = d.Name, TotalGB = Math.Round(d.TotalSize / 1073741824.0, 1), FreeGB = Math.Round(d.AvailableFreeSpace / 1073741824.0, 1) }) .ToList(); return Task.FromResult(new Packet { Type = PacketType.DiskInfoResult, Payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(list)) }); } }服务端只需要在Handlers注册表里补一条映射,界面侧加个按钮触发即可,不用碰任何通信代码。这就是把switch换成接口注册的收益。
改造时几个原则值得记住。第一,新指令一律带Result后缀成对出现,请求和响应分开,方便在commands表里追状态。第二,返回值统一 JSON,别一半 JSON 一半自定义分隔符,半年后自己都记不住。第三,能只读就别写,磁盘查询、进程列表、服务状态这类只读指令风险最低,涉及删除、重启、改配置的指令一定要加二次确认并且在服务端留审计记录。
| 改造方向 | 改动文件 | 工作量 | 风险 |
|---|---|---|---|
| 新增只读查询指令 | PacketType + 一个 Handler | 半小时 | 低 |
| 新增文件下载 | PacketType + Handler + 分片逻辑 | 半天 | 中 |
| 换 Protobuf 序列化 | Common 全量 + 两端 | 两天 | 中高 |
| 加多级分组与批量下发 | SessionManager + UI + 表结构 | 三天 | 高 |
最后一条教训是我自己付过学费的:加完新指令别急着全量推客户端,找两台机器灰度跑一天,重点看commands表里status有没有卡在 1 的。有一回我图快,一条新指令的PacketType和旧的FileDownload撞了号,全公司三百多台机器同时收到错包,客户端处理逻辑直接抛异常,整个下午在写道歉邮件。从那以后我每次加枚举值都强制走一遍「全局搜索这个数字确认没被占用」的检查,宁可慢五分钟。希望帮到你。
本文还有配套的精品资源,点击获取