| | |
| | | using HslCommunication.Core.IMessage; |
| | | using System; |
| | | using System.Net.Sockets; |
| | | using System.Text; |
| | | using System.Text.Json; |
| | | using System.Threading; |
| | | using System.Threading.Tasks; |
| | | using WIDESEAWCS_QuartzJob; |
| | | using System.IO; |
| | | |
| | | namespace WIDESEAWCS_Tasks.SocketServer |
| | | { |
| | | public partial class TcpSocketServer |
| | | { |
| | | /// <summary> |
| | | /// 处理客户端连接的消息循环 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 持续接收客户端消息,直到连接断开或取消。 |
| | | /// 处理流程: |
| | | /// 1. 接收消息(帧解析) |
| | | /// 2. 更新客户端状态(活跃时间、编码) |
| | | /// 3. 处理设备注册 |
| | | /// 4. 触发 MessageReceived 事件 |
| | | /// 连接断开时清理资源并触发 RobotReceived 事件。 |
| | | /// </remarks> |
| | | /// <param name="client">TCP 客户端连接</param> |
| | | /// <param name="clientId">客户端唯一标识</param> |
| | | /// <param name="cancellationToken">取消令牌</param> |
| | | /// <param name="robotCrane">机器人状态</param> |
| | | public async Task HandleClientAsync(TcpClient client, string clientId, CancellationToken cancellationToken, RobotSocketState robotCrane) |
| | | { |
| | | using (client) |
| | |
| | | CancellationTokenSource? localCts = null; |
| | | if (_options.EnableHeartbeat || _options.IdleTimeoutSeconds > 0) |
| | | { |
| | | // 创建链接的取消令牌源 |
| | | localCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
| | | } |
| | | |
| | | try |
| | | { |
| | | // 消息接收循环 |
| | | while (!cancellationToken.IsCancellationRequested && client.Connected) |
| | | { |
| | | string? message; |
| | | try |
| | | { |
| | | var ct = localCts?.Token ?? cancellationToken; |
| | | message = await reader.ReadLineAsync().WaitAsync(ct); |
| | | // 接收完整消息(帧解析) |
| | | message = await ReceiveFullMessageAsync(networkStream, _textEncoding, ct); |
| | | } |
| | | catch (OperationCanceledException) |
| | | { |
| | |
| | | break; |
| | | } |
| | | |
| | | // 更新客户端状态 |
| | | UpdateClientStatus(clientId, message); |
| | | |
| | | string messageLower = message.ToLowerInvariant(); |
| | | |
| | | // 处理注册消息 |
| | | if (TryHandleRegister(messageLower, message, clientId, networkStream, cancellationToken)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | // 触发消息接收事件 |
| | | if (MessageReceived != null) |
| | | { |
| | | try { _ = MessageReceived.Invoke(message, false, client, robotCrane); } catch { } |
| | | try |
| | | { |
| | | // 判断是否为 JSON 格式 |
| | | bool isJsonFormat = TryParseJsonSilent(message); |
| | | _ = MessageReceived.Invoke(message, isJsonFormat, client, robotCrane); |
| | | } |
| | | catch { } |
| | | } |
| | | } |
| | | } |
| | | finally |
| | | { |
| | | // 清理资源 |
| | | try { localCts?.Cancel(); localCts?.Dispose(); } catch { } |
| | | RemoveClient(clientId); |
| | | try { _ = RobotReceived.Invoke(clientId); } catch { } |
| | |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 处理设备注册消息 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 注册消息格式:register,{deviceId} |
| | | /// 将设备 ID 绑定到当前客户端 ID。 |
| | | /// </remarks> |
| | | /// <param name="messageLower">消息小写版本</param> |
| | | /// <param name="message">原始消息</param> |
| | | /// <param name="clientId">客户端 ID</param> |
| | | /// <param name="networkStream">网络流</param> |
| | | /// <param name="cancellationToken">取消令牌</param> |
| | | /// <returns>是否处理了注册消息</returns> |
| | | private bool TryHandleRegister(string messageLower, string message, string clientId, NetworkStream networkStream, CancellationToken cancellationToken) |
| | | { |
| | | if (!messageLower.StartsWith("register,")) |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 提取设备 ID |
| | | string deviceId = message.Substring("register,".Length).Trim(); |
| | | if (!string.IsNullOrEmpty(deviceId)) |
| | | { |
| | | lock (_syncRoot) |
| | | { |
| | | // 绑定设备到客户端 |
| | | _deviceBindings[deviceId] = clientId; |
| | | } |
| | | |
| | | // 回复注册成功 |
| | | _ = WriteToClientAsync(clientId, networkStream, $"Registered,{deviceId}", cancellationToken); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 更新客户端状态 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 更新最后活跃时间和字符编码。 |
| | | /// 如果开启了自动编码检测,根据消息内容判断是 UTF-8 还是 GBK。 |
| | | /// </remarks> |
| | | /// <param name="clientId">客户端 ID</param> |
| | | /// <param name="message">最新接收的消息</param> |
| | | private void UpdateClientStatus(string clientId, string message) |
| | | { |
| | | lock (_syncRoot) |
| | | { |
| | | // 更新最后活跃时间 |
| | | _clientLastActive[clientId] = DateTime.Now; |
| | | |
| | | // 如果还没有记录编码 |
| | | if (!_clientEncodings.ContainsKey(clientId)) |
| | | { |
| | | if (_options.AutoDetectEncoding && _autoDetectedGb2312 != null) |
| | | { |
| | | // 自动检测编码:JSON 或 UTF-8 字节特征则用 UTF-8,否则用 GBK |
| | | bool isUtf8 = TryParseJsonSilent(message) || IsLikelyUtf8(_textEncoding.GetBytes(message)); |
| | | _clientEncodings[clientId] = isUtf8 ? _textEncoding : _autoDetectedGb2312; |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 异步发送消息到客户端 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 内部方法,不使用帧格式,直接发送原始消息。 |
| | | /// </remarks> |
| | | private async Task WriteToClientAsync(string clientId, NetworkStream networkStream, string message, CancellationToken cancellationToken) |
| | | { |
| | | SemaphoreSlim? sem = null; |
| | |
| | | if (sem != null) await sem.WaitAsync(cancellationToken); |
| | | try |
| | | { |
| | | var data = enc.GetBytes(message + "\n"); |
| | | var framedMessage = BuildFramedMessage(message); |
| | | var data = enc.GetBytes(framedMessage); |
| | | await networkStream.WriteAsync(data, 0, data.Length, cancellationToken); |
| | | } |
| | | finally |
| | |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 构建帧消息 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 在消息前后添加头尾标识。 |
| | | /// </remarks> |
| | | /// <param name="message">原始消息</param> |
| | | /// <returns>带帧标识的消息</returns> |
| | | private string BuildFramedMessage(string message) |
| | | { |
| | | var header = _options.MessageHeader ?? string.Empty; |
| | | var footer = _options.MessageFooter ?? string.Empty; |
| | | return header + (message ?? string.Empty) + footer; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 静默尝试解析 JSON |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 判断消息是否以 { 或 [ 开头,如果是则尝试解析。 |
| | | /// 解析失败不抛异常。 |
| | | /// </remarks> |
| | | /// <param name="message">消息内容</param> |
| | | /// <returns>是否是有效的 JSON 格式</returns> |
| | | private static bool TryParseJsonSilent(string message) |
| | | { |
| | | if (string.IsNullOrWhiteSpace(message)) return false; |
| | |
| | | try { JsonDocument.Parse(message); return true; } catch { return false; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 判断字节数组是否为 UTF-8 编码 |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 通过检查字节序列是否符合 UTF-8 多字节字符的编码规则。 |
| | | /// </remarks> |
| | | /// <param name="data">字节数组</param> |
| | | /// <returns>是否可能是 UTF-8 编码</returns> |
| | | private static bool IsLikelyUtf8(byte[] data) |
| | | { |
| | | int i = 0; |
| | | while (i < data.Length) |
| | | { |
| | | byte b = data[i]; |
| | | if (b <= 0x7F) { i++; continue; } |
| | | if (b >= 0xC2 && b <= 0xDF) |
| | | if (b <= 0x7F) { i++; continue; } // ASCII 字符 |
| | | |
| | | // 检查多字节字符 |
| | | if (b >= 0xC2 && b <= 0xDF) // 2字节字符 |
| | | { |
| | | if (i + 1 >= data.Length) return false; |
| | | if ((data[i + 1] & 0xC0) != 0x80) return false; |
| | | i += 2; continue; |
| | | } |
| | | if (b >= 0xE0 && b <= 0xEF) |
| | | if (b >= 0xE0 && b <= 0xEF) // 3字节字符 |
| | | { |
| | | if (i + 2 >= data.Length) return false; |
| | | if ((data[i + 1] & 0xC0) != 0x80 || (data[i + 2] & 0xC0) != 0x80) return false; |
| | | i += 3; continue; |
| | | } |
| | | if (b >= 0xF0 && b <= 0xF4) |
| | | if (b >= 0xF0 && b <= 0xF4) // 4字节字符 |
| | | { |
| | | if (i + 3 >= data.Length) return false; |
| | | if ((data[i + 1] & 0xC0) != 0x80 || (data[i + 2] & 0xC0) != 0x80 || (data[i + 3] & 0xC0) != 0x80) return false; |
| | |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 接收完整消息(帧解析) |
| | | /// </summary> |
| | | /// <remarks> |
| | | /// 根据配置的头尾标识解析消息。 |
| | | /// 如果未配置头尾,则一直读到数据不可用。 |
| | | /// </remarks> |
| | | /// <param name="networkStream">网络流</param> |
| | | /// <param name="encoding">字符编码</param> |
| | | /// <param name="cancellationToken">取消令牌</param> |
| | | /// <returns>接收到的消息</returns> |
| | | private async Task<string?> ReceiveFullMessageAsync(NetworkStream networkStream, Encoding encoding, CancellationToken cancellationToken) |
| | | { |
| | | var header = _options.MessageHeader ?? string.Empty; |
| | | var footer = _options.MessageFooter ?? string.Empty; |
| | | |
| | | var buffer = new byte[1024]; |
| | | var builder = new StringBuilder(); |
| | | |
| | | while (true) |
| | | { |
| | | // 读取数据 |
| | | int bytesRead = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); |
| | | if (bytesRead <= 0) |
| | | { |
| | | if (builder.Length == 0) return null; |
| | | // 无头尾配置时,返回已有数据 |
| | | return string.IsNullOrEmpty(header) && string.IsNullOrEmpty(footer) ? builder.ToString() : null; |
| | | } |
| | | |
| | | builder.Append(encoding.GetString(buffer, 0, bytesRead)); |
| | | |
| | | // 如果没有配置头尾,且数据不可用,返回已有数据 |
| | | if (string.IsNullOrEmpty(header) && string.IsNullOrEmpty(footer)) |
| | | { |
| | | if (!networkStream.DataAvailable) |
| | | { |
| | | break; |
| | | } |
| | | continue; |
| | | } |
| | | |
| | | // 查找帧头 |
| | | var data = builder.ToString(); |
| | | var headerIndex = string.IsNullOrEmpty(header) ? 0 : data.IndexOf(header, StringComparison.Ordinal); |
| | | if (headerIndex < 0) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | // 提取帧内容 |
| | | var startIndex = headerIndex + header.Length; |
| | | var footerIndex = string.IsNullOrEmpty(footer) ? data.Length : data.IndexOf(footer, startIndex, StringComparison.Ordinal); |
| | | if (footerIndex >= 0) |
| | | { |
| | | return data.Substring(startIndex, footerIndex - startIndex); |
| | | } |
| | | } |
| | | |
| | | return builder.ToString(); |
| | | } |
| | | } |
| | | } |
| | | } |