1. 老项目里那套 Socket 客户端,为什么还要折腾
.NET 3.5 的异步 Socket 客户端,放到今天依然有它的生存空间。很多工控上位机、老 ERP 插件、桌面采集端还锁在 3.5 框架上,升级一次成本高得吓人,但业务又要求把数据往统一通道上送。这时候你手里能用的牌不多:BeginConnect/EndConnect那套 APM 写起来回调套回调,Thread加阻塞Receive又容易把 UI 卡死。SocketAsyncEventArgs是 3.5 里少数既能扛并发、又不依赖新框架的异步模型,它把一次连接、一次收发都抽象成一个可复用的事件参数对象,配合对象池能显著减少 GC 压力。
这篇聚焦的是「客户端」侧:用SocketAsyncEventArgs写一个能连、能收、能发、断了能重连的最小骨架,并且把 TaoToken 统一 Key 和 API 通道的配置放进app.config,让老框架项目不用改架构就能接入统一通道。适合谁?手上维护着 .NET 3.5 项目、需要把设备数据或业务请求走统一 API 通道的开发者。读完你能拿到一份可直接粘贴的配置骨架、一段可运行的客户端代码,以及一次明确的连通性验证动作和预期结果。
需要先说明一点:TaoToken 在这里扮演的是「统一 Key + API 通道」的角色,客户端通过它去访问模型对话、Coding Plan 等能力,而不是替代你的 Socket 通信本身。Socket 负责传输,TaoToken 负责鉴权和路由,两者是配合关系。
2. TaoToken 前置:统一 Key 与 API 通道怎么摆进 app.config
在动手写 Socket 之前,先把配置层理清楚。老项目最忌讳把 Key 硬编码进.cs文件,一旦要换环境就得重新编译。app.config的appSettings是最省事的落点,3.5 原生支持,读取用ConfigurationManager.AppSettings即可。
你需要先在 TaoToken 控制台拿到统一 Key。入口在官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,登录后进控制台创建 API Key,具体页面是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。Key 的管理和查看在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,接入细节看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。API 基地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,配置里直接写死即可。
配置骨架长这样,我把它拆成「通道地址」「鉴权」「Socket 行为」三组,方便你按环境替换:
<?xml version="1.0" encoding="utf-8"?> <configuration> <appSettings> <!-- TaoToken 统一通道 --> <add key="TaoToken.ApiBase" value="https://taotoken.net/api" /> <add key="TaoToken.ApiKey" value="sk-你的统一Key" /> <add key="TaoToken.Model" value="claude-3-5-sonnet" /> <!-- Socket 客户端行为 --> <add key="Socket.Host" value="127.0.0.1" /> <add key="Socket.Port" value="9000" /> <add key="Socket.ConnectTimeoutMs" value="5000" /> <add key="Socket.BufferSize" value="8192" /> <add key="Socket.ReconnectDelayMs" value="3000" /> <add key="Socket.MaxReconnect" value="5" /> </appSettings> </configuration>注意:
TaoToken.ApiKey不要提交到版本库。老项目常用做法是放一份app.config.template,真实 Key 由部署脚本注入,或者用机器级环境变量覆盖。
读取封装一个静态类,避免到处写字符串:
public static class AppConfig { public static string ApiBase { get { return ConfigurationManager.AppSettings["TaoToken.ApiBase"]; } } public static string ApiKey { get { return ConfigurationManager.AppSettings["TaoToken.ApiKey"]; } } public static string Host { get { return ConfigurationManager.AppSettings["Socket.Host"]; } } public static int Port { get { return int.Parse(ConfigurationManager.AppSettings["Socket.Port"]); } } public static int BufferSize { get { return int.Parse(ConfigurationManager.AppSettings["Socket.BufferSize"]); } } public static int ReconnectDelayMs { get { return int.Parse(ConfigurationManager.AppSettings["Socket.ReconnectDelayMs"]); } } public static int MaxReconnect { get { return int.Parse(ConfigurationManager.AppSettings["Socket.MaxReconnect"]); } } }这里有个容易忽略的点:.NET 3.5 的ConfigurationManager在System.Configuration程序集里,项目引用里要手动加上,否则编译报「找不到类型或命名空间」。这是老项目接入时第一个坑,先记下。
3. 可复制配置:SocketAsyncEventArgs 客户端骨架
下面这段是核心。设计思路是:一个AsyncSocketClient类持有Socket实例,用两个SocketAsyncEventArgs分别负责收和发,连接、接收、发送都走SocketAsyncEventArgs的完成回调。断线重连用一个简单的重试计数加定时器。
先看连接部分。ConnectAsync在 3.5 里通过SocketAsyncEventArgs的Completed事件回调:
using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; public class AsyncSocketClient { private Socket _socket; private readonly SocketAsyncEventArgs _connectArgs; private readonly SocketAsyncEventArgs _receiveArgs; private readonly SocketAsyncEventArgs _sendArgs; private readonly byte[] _receiveBuffer; private int _reconnectCount; private bool _manualClose; public event Action<string> OnMessage; public event Action<bool> OnConnectionChanged; public AsyncSocketClient() { _receiveBuffer = new byte[AppConfig.BufferSize]; _connectArgs = new SocketAsyncEventArgs(); _connectArgs.RemoteEndPoint = new DnsEndPoint(AppConfig.Host, AppConfig.Port); _connectArgs.Completed += OnConnectCompleted; _receiveArgs = new SocketAsyncEventArgs(); _receiveArgs.SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length); _receiveArgs.Completed += OnReceiveCompleted; _sendArgs = new SocketAsyncEventArgs(); _sendArgs.Completed += OnSendCompleted; } public void Connect() { _manualClose = false; _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); bool pending = _socket.ConnectAsync(_connectArgs); if (!pending) OnConnectCompleted(_socket, _connectArgs); }连接完成的回调里判断SocketError,成功就挂上接收:
private void OnConnectCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError == SocketError.Success) { _reconnectCount = 0; RaiseConnection(true); StartReceive(); } else { RaiseConnection(false); ScheduleReconnect(); } } private void StartReceive() { if (_socket == null) return; bool pending = _socket.ReceiveAsync(_receiveArgs); if (!pending) OnReceiveCompleted(_socket, _receiveArgs); }接收回调是数据入口,注意BytesTransferred == 0表示对端关闭,要触发重连:
private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError == SocketError.Success && e.BytesTransferred > 0) { string msg = Encoding.UTF8.GetString(e.Buffer, e.Offset, e.BytesTransferred); if (OnMessage != null) OnMessage(msg); StartReceive(); } else { RaiseConnection(false); ScheduleReconnect(); } }发送部分把待发数据拷进SocketAsyncEventArgs的缓冲。3.5 里没有Memory,用SetBuffer加偏移即可:
public void Send(string text) { if (_socket == null || !_socket.Connected) return; byte[] data = Encoding.UTF8.GetBytes(text); _sendArgs.SetBuffer(data, 0, data.Length); bool pending = _socket.SendAsync(_sendArgs); if (!pending) OnSendCompleted(_socket, _sendArgs); } private void OnSendCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError != SocketError.Success) { RaiseConnection(false); ScheduleReconnect(); } }重连用Timer做延迟,避免断线瞬间疯狂重试:
private void ScheduleReconnect() { if (_manualClose) return; if (_reconnectCount >= AppConfig.MaxReconnect) return; _reconnectCount++; Timer t = null; t = new Timer(state => { t.Dispose(); try { Connect(); } catch { ScheduleReconnect(); } }, null, AppConfig.ReconnectDelayMs, Timeout.Infinite); } private void RaiseConnection(bool ok) { if (OnConnectionChanged != null) OnConnectionChanged(ok); } public void Close() { _manualClose = true; if (_socket != null) { try { _socket.Shutdown(SocketShutdown.Both); } catch { } _socket.Close(); _socket = null; } } }这段骨架刻意保持最小:没有做粘包拆包,没有做心跳。真实项目里这两块必须补,但那是另一个话题,先把连通性跑通。
4. 验证请求:一次可执行的连通性动作与预期结果
代码写完了,怎么确认它真的通了?分两步:先验证 TaoToken 统一通道本身可达,再验证 Socket 客户端能连上你的服务端。
第一步,用命令行验证 TaoToken 通道。打开 cmd,执行:
curl -X POST https://taotoken.net/api/v1/chat/completions ^ -H "Authorization: Bearer sk-你的统一Key" ^ -H "Content-Type: application/json" ^ -d "{\"model\":\"claude-3-5-sonnet\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}"预期结果是返回一段 JSON,包含choices字段和模型回复内容。如果返回 401,说明 Key 不对;返回 404,检查路径是不是/api/v1/chat/completions。这一步通了,说明统一 Key 和 API 通道没问题。想更直观地看模型返回,可以直接用模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 手动发一条消息对照。
第二步,验证 Socket 客户端。写一个控制台入口,连上后发一条消息,观察回调:
class Program { static void Main() { var client = new AsyncSocketClient(); client.OnConnectionChanged += ok => Console.WriteLine("连接状态: " + ok); client.OnMessage += msg => Console.WriteLine("收到: " + msg); client.Connect(); Console.WriteLine("按回车发送测试消息..."); Console.ReadLine(); client.Send("hello from .net 3.5 client"); Console.ReadLine(); client.Close(); } }预期输出顺序是:先打印「连接状态: True」,然后你回车后服务端如果回显,会打印「收到: ...」。如果一直停在「连接状态: False」,说明Socket.Host和Socket.Port指向的服务端没起来,或者防火墙拦了。实测下来,最容易出问题的是端口写错和DnsEndPoint解析失败,先在本地用telnet 127.0.0.1 9000确认端口通不通。
提示:如果你暂时没有自己的 Socket 服务端,可以先用一个本地
TcpListener起个回显服务来验证客户端逻辑,确认收发和重连都正常后,再换成真实服务端。
5. 本篇常见错排查
老框架下跑这套代码,报错集中在几个地方,我按出现频率排一下。
第一个是编译期报「未能找到类型或命名空间名称 SocketAsyncEventArgs」。这通常是因为项目目标框架不是 .NET 3.5,或者引用了错误的System.dll。检查项目属性里的目标框架,确认是 3.5,并且System引用正常。
第二个是运行时SocketException: 由于目标计算机积极拒绝,无法连接。这是服务端没监听对应端口,不是客户端代码问题。用netstat -ano | findstr 9000看端口有没有被监听。
第三个是重连风暴。如果ScheduleReconnect里没有_reconnectCount上限,断线后会无限重试,日志刷屏。上面代码里加了MaxReconnect,但真实项目建议再加指数退避,比如延迟按ReconnectDelayMs * (1 << _reconnectCount)增长。
第四个是SetBuffer复用导致的脏数据。_sendArgs和_receiveArgs是复用的,如果发送时数据长度小于上次,BytesTransferred可能读到旧内容。解决办法是每次发送都重新SetBuffer,接收时严格按e.BytesTransferred截取,不要读整个 buffer。
第五个是ConfigurationManager读不到配置。老项目里app.config必须和 exe 同名同目录,单元测试项目里读的是App.config而不是app.config,大小写和文件名都要对。
第六个是跨线程更新 UI。OnMessage回调跑在 IO 线程上,如果直接更新 WinForm 控件会抛「线程间操作无效」。用Control.Invoke或BeginInvoke包一层。
6. 接入之后:把统一通道用起来
连通性验证通过后,下一步就是把 TaoToken 的能力真正接进业务。如果你只是偶尔调一下模型做验证,用模型对话页面最省事;如果是长期在 IDE 里做编码辅助、或者要跑 Agent 类任务,建议直接上 Coding Plan,把统一 Key 配进开发工具,省去每次手动拼请求的麻烦。Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,配置方式文档里有说明。
回到 Socket 本身,这套骨架只是起点。真实项目里你至少还要补三块:粘包拆包(按长度前缀或分隔符切分)、心跳保活(定时发 ping,超时判定断线)、以及发送队列(避免多线程同时SendAsync导致SocketAsyncEventArgs被并发复用)。这三块补上,客户端才算能在生产环境跑。
最后留一个我踩过的坑:SocketAsyncEventArgs的Completed事件在同步完成时不会触发,所以每次调用ConnectAsync、ReceiveAsync、SendAsync后都要判断返回值,false就手动调一次回调。上面代码里每处都做了这个判断,漏掉任何一处都会导致「有时候通、有时候卡死」的诡异现象。