这两天服务器和客户端进行了webscoket的联调,在和C#的webscoket实现联调的过程中,发现一些有趣的事情。
在我自己C++的实现中,webscoket对上层应用而言是完全透明的,webscoket 只是一个传输协议,用户对此不需要有任何关注,一切都自动进行,包括连接,握手升级,帧切割,帧拼合,控制帧管理,心跳这些,对外完全透明。
微软.net 中的webscoket实现就显得非常难以理解,握手需要用户主动去调用,这个从HTTP升级的操作还能理解:
cs
public class WebSocketController : ControllerBase
{
[Route("/ws")]
public async Task Get()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
await Echo(webSocket);
}
else
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
}
}
}
但是到了发送和接收阶段,先看看这两个函数的示例代码:
cs
private async Task Echo(HttpContext context, WebSocket webSocket)
{
var buffer = new byte[1024 * 4];
WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
while (!result.CloseStatus.HasValue)
{
await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
}
首先,接受函数需要指定一个缓冲区,由缓冲区来接受webscoket上传输的数据,这个属于常规操作,继续看发送函数, 你会发现在参数中,有两个参数:MessageType 和 EndOfMessage,看看微软怎么描述的:
参数
buffer
要通过连接发送的缓冲区。
messageType
指示应用程序是发送二进制消息还是发送文本消息。
endOfMessage
指示"缓冲区"中的数据是否是消息的最后一部分。
cancellationToken
传播有关应取消操作的通知的标记。
关于 WebSocketMessageType
|--------|---|------------------|
| Binary | 1 | 消息采用二进制格式。 |
| Close | 2 | 因为收到关闭的消息,接受已完成。 |
| Text | 0 | 该消息是明文形式。 |
如果看过RFC6455是不是感到有些似曾相识?微软做了一次封装,但是也混淆了一些概念,把本应由WebSocket 内部处理的逻辑暴露给外部的调用。我暂时还没想明白微软为啥要这么设计,在我理想的模型里面,包括数据切割分段发送这些事情,不应该是WebScoket的实现自动处理吗?特别是endOfMessage这个布尔值,更让人费解。这个参数对应的opcode 中的 Fin 位?文档中没有说明。客户端在写代码的时候,暂时没有改变这个值,直接设置的 true。
而且关于subprotocol,微软也没有给出一个明确的框架,只是给出一个string,作为subprotocol的标志。我猜想,微软把这个暴露出来,也许就是为subprotocol留下可操作的空间,但文档里面没有明确的说明。在我看来,一个好的设计,应该保持某种程度的兼容性,比如和Scoket的SendAsync保持某种程度的一致。subprotocol应该设计成WebSocket的一个子对象,微软提供一个标准的调用接口,将所有协议细节都放在子协议接口之中,而不是像这样暴露出来。
另外还有一个问题,就是WebScoket的ping / pong心跳检测机制。我翻遍了微软提供的文档,只是这么提到ping和pong:
处理客户端连接断开
当客户端由于失去连接而断开连接时,不会自动向服务器发送通知。 服务器只有在客户端发送通知时才会收到断开连接消息,而此操作无法在失去 Internet 连接的情况下进行。 如果想要在发生此情况时采取某个操作,在特定时间范围内未收到来自客户端的任何消息后设置超时。
如果客户端并非总是发送消息,并且你不希望仅由于连接进入空闲状态就设置超时,则让客户端使用一个计时器并每隔 X 秒发送一条 ping 消息。 在服务器上,如果某条消息在上一条消息发出后的 2*X 秒内尚未到达,则终止连接并报告客户端已断开连接。 等待两次预测的时间间隔,以便为可能延迟 ping 消息的网络延迟提供额外的时间。
备注
如果
KeepAliveInterval
选项(默认为 30 秒 (TimeSpan.FromSeconds(30)
))大于零,则内部 ManagedWebSocket 将隐式处理 Ping/Pong 帧,以使连接保持活动状态。
可以看到,微软是实现了心跳检测的,KeepAliveInterval那个选项我经过测试,确定为心跳包的间隔时间,经过测试,发现微软的Ping控制帧是没有附带Payload的,客户端发送到服务器,包括掩码一共6个字节,服务器返回Pong给客户端是两个字节,如果服务器发送附带Payload的Ping帧,微软的WebScoket可以原封不动的把Payload通过Pong帧给服务器返回。
测试中,发现了一个比较棘手的问题,即客户端虽然会发送Ping帧给服务器,但如果服务器不返回任何Pong帧,微软的WebSocket不会有任何动作,不会抛异常,虽然RFC6455没有明确规定心跳帧无法检测的情况该如何处理,但好歹抛一个异常或则事件通知我们?而且关于这一点,微软倒是明确说了:在服务器上,如果某条消息在上一条消息发出后的 2*X 秒内尚未到达,则终止连接并报告客户端已断开连接,那客户端呢?RFC6455是允许双向检测心跳的,既然检测到心跳失败,那么怎么就不留个通知的方法?