
本文深入探讨了php websocket在高速传输数据时出现乱码的常见问题。核心原因在于客户端为提高效率,可能在一个tcp包中发送多个websocket帧,而服务器端的`unseal`函数未能正确解析和截断这些帧。文章详细分析了现有`unseal`函数的缺陷,并提供了一个优化后的递归解析方案,确保每个websocket帧都能被准确解码,从而解决乱码问题。
理解WebSocket帧与TCP数据包
在构建基于WebSocket的实时通信应用时,开发者可能会遇到一个令人困惑的问题:当客户端以极高的频率发送数据时,服务器端接收到的消息却出现乱码。这通常发生在客户端为了网络传输效率,将多个WebSocket帧打包到一个单一的TCP数据包中发送时。
WebSocket协议在TCP之上运行,它定义了自己的数据帧格式。每个WebSocket消息都封装在一个或多个帧中,每个帧包含头部信息(如FIN位、操作码、掩码位和载荷长度指示器)以及实际的载荷数据。当客户端(例如,浏览器JavaScript)通过websocket.send()方法快速连续发送多条消息时,底层TCP/IP协议栈可能会将这些独立的WebSocket帧合并成一个更大的TCP数据包,一次性发送给服务器。
服务器端接收到这个包含多个WebSocket帧的TCP数据包后,需要一个机制来正确地识别和解析其中的每一个帧。如果服务器的帧解析逻辑未能考虑到一个TCP包中可能包含多个帧的情况,它就会尝试将整个TCP包的内容作为一个单一的WebSocket帧来处理,从而导致后续帧的头部信息被误认为是前一个帧的载荷数据,最终表现为乱码。
现有unseal函数的局限性分析
问题中提供的unseal函数是一个常见的WebSocket帧解码实现,但它存在一个关键缺陷,导致无法正确处理包含多帧的TCP数据包:
立即学习“PHP免费学习笔记(深入)”;
function unseal($socketData) {
$length = ord($socketData[1]) & 127; // 获取载荷长度指示器
if($length == 126) {
$masks = substr($socketData, 4, 4);
$data = substr($socketData, 8); // 从固定位置开始截取数据,直到字符串末尾
}
elseif($length == 127) {
$masks = substr($socketData, 10, 4);
$data = substr($socketData, 14); // 从固定位置开始截取数据,直到字符串末尾
}
else {
$masks = substr($socketData, 2, 4);
$data = substr($socketData, 6); // 从固定位置开始截取数据,直到字符串末尾
}
$socketData = ""; // 变量名易混淆,应为$unmaskedData
for ($i = 0; $i < strlen($data); ++$i) {
$socketData .= $data[$i] ^ $masks[$i%4];
}
return $socketData;
}该函数的缺陷在于:
- 未充分利用实际载荷长度信息:虽然代码中通过$length变量获取了载荷长度指示器,并根据其值确定了掩码和数据开始的偏移量,但当真正提取载荷数据时(例如$data = substr($socketData, 8);),它只是简单地从某个固定偏移量开始,一直截取到整个$socketData字符串的末尾。
- 假设一个TCP包只包含一个WebSocket帧:这种截取方式意味着函数错误地假定$socketData中只包含一个完整的WebSocket帧。如果$socketData实际上包含了多个帧,那么第一个帧的$data变量将包含其自身的载荷以及后续帧的所有数据。
- 未递归处理剩余数据:由于没有根据第一个帧的实际载荷长度进行精确截断,也没有机制来处理第一个帧之后可能存在的剩余数据(即下一个WebSocket帧),因此后续帧的头部信息会被错误地作为当前帧的载荷进行解掩码,导致乱码。
要解决此问题,关键在于精确地计算出当前帧的实际载荷长度,并根据这个长度准确地截取载荷数据。处理完一个帧后,如果原始$socketData中仍有剩余数据,则应将其视为下一个WebSocket帧的开始,并递归或迭代地进行解析。
优化unseal函数以支持多帧处理
为了正确解析包含多帧的TCP数据包,我们需要对unseal函数进行优化。核心思想是:精确计算当前帧的实际载荷长度,提取并解掩码当前帧,然后检查是否有剩余数据,并递归地处理剩余数据。
以下是优化后的unseal函数实现:
= strlen($socketData)) {
// 数据不完整,无法解析帧头,通常需要等待更多数据
error_log("WebSocket frame incomplete: Not enough data for initial bytes.");
break;
}
// 读取第二个字节,包含掩码位和载荷长度指示器
$byte1 = ord($socketData[$offset + 1]);
$isMasked = ($byte1 >> 7) & 0x1; // 掩码位
$payloadLengthIndicator = $byte1 & 0x7F; // 载荷长度指示器 (0-125, 126, 127)
$actualPayloadLength = 0; // 实际载荷长度
$maskingKeyStart = 0; // 掩码键的起始偏移量
$payloadDataStart = 0; // 载荷数据的起始偏移量
// 根据载荷长度指示器确定实际载荷长度和各部分的偏移量
if ($payloadLengthIndicator == 126) {
// 载荷长度由接下来的2个字节表示 (16位无符号整数)
if ($offset + 3 >= strlen($socketData)) {
error_log("WebSocket frame incomplete: Not enough data for 16-bit length.");
break;
}
$actualPayloadLength = unpack('n', substr($socketData, $offset + 2, 2))[1];
$maskingKeyStart = $offset + 4; // FIN/Opcode (1) + Mask/Length (1) + Extended Length (2) = 4
$payloadDataStart = $offset + 8; // 掩码键 (4) 之后
} elseif ($payloadLengthIndicator == 127) {
// 载荷长度由接下来的8个字节表示 (64位无符号整数)
// 注意:PHP的unpack 'J' (64位无符号) 在某些系统或PHP版本上可能存在兼容性问题
// 对于WebSockets,超过65535字节的消息通常不常见,如果需要处理超大消息,请确保环境支持或使用自定义逻辑
if ($offset + 9 >= strlen($socketData)) {
error_log("WebSocket frame incomplete: Not enough data for 64-bit length.");
break;
}
// 假设 'J' 适用于64位无符号整数
$actualPayloadLength = unpack('J', substr($socketData, $offset + 2, 8))[1];
$maskingKeyStart = $offset + 10; // FIN/Opcode (1) + Mask/Length (1) + Extended Length (8) = 10
$payloadDataStart = $offset + 14; // 掩码键 (4) 之后
} else {
// 载荷长度直接由载荷长度指示器表示 (0-125)
$actualPayloadLength = $payloadLengthIndicator;
$maskingKeyStart = $offset + 2; // FIN/Opcode (1) + Mask/Length (1) = 2
$payloadDataStart = $offset + 6; // 掩码键 (4) 之后
}
// 验证掩码键和载荷数据是否完整
if (!$isMasked || ($maskingKeyStart + 4 > strlen($socketData)) || ($payloadDataStart + $actualPayloadLength > strlen($socketData))) {
error_log("WebSocket frame malformed or incomplete: Masking key or payload data missing. isMasked: " . (int)$isMasked . " actualPayloadLength: " . $actualPayloadLength . " dataLen: " . strlen($socketData));
break; // 帧不完整或格式错误,无法继续解析
}
// 提取掩码键和载荷数据
$masks = substr($socketData, $maskingKeyStart, 4);
$payload = substr($socketData, $payloadDataStart, $actualPayloadLength);
$unmaskedPayload = '';
// 解掩码
for ($i = 0; $i < $actualPayloadLength; ++$i) {
$unmaskedPayload .= $payload[$i] ^ $masks[$i % 4];
}
$messages[] = $unmaskedPayload; // 将解码后的消息添加到结果数组
// 更新偏移量,指向下一个可能的帧的起始位置
$offset = $payloadDataStart + $actualPayloadLength;
}
return $messages;
}代码解析与改进点:
- 迭代处理多帧:新的unseal函数采用while循环,而不是递归。迭代方式通常更健壮,可以避免深层递归可能导致的栈溢出问题,尤其是在一个TCP包中包含大量小帧











