写了一个WebSocket客户端

  1. 1. 如何调试
  2. 2. 连接ws服务器,发消息总是提示 frame 错误
  3. 3. 关于“粘包”,TCP协议不背锅
  4. 4. 多线程?单线程?
  5. 5. 跨平台

写了一个WebSocket客户端

以前就接触过 tcp、udp 的编程,但是一直停留在 “Hello World” 的水平。这一次想写WebSocket客户端,是因为现有的库都非常巨大,但是我只需要一个能收消息的客户端。

之前一直用的是 easywsclient 这个库,只有一个头文件和一个源文件,简单好用。看源代码也没有很复杂,就自己做了一个“复刻版”(LightWebSocketClient)。

WebSocket 协议是有标准的,具体可以在 RFC6455 看到。协议的内容挺长的,但大可不必被吓倒,因为都很好理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+

写这个库花了两天时间,其中大部分时间都在研究怎么写 tcp socket 相关的代码。

下面是写这个库遇到的问题和一些经验。

如何调试

使用 nodejs 的 ws 库。几行代码就能创建一个 WebSocket 服务器。

连接ws服务器,发消息总是提示 frame 错误

这个问题解决起来特别简单,但是发现问题的来源花了特别久。其实就是发送建立请求的字符串的时候,多发了个’\0’。

这个 ‘\0’,储存到了ws服务器的buffer中。于是我的第一帧内容的开头就多了个’\0’,服务器看不懂了,就报错崩溃了。

我修改了 server 端的代码,把server 收到的 buffer 输出出来,才发现的这个错误。

关于“粘包”,TCP协议不背锅

现在去搜索“TCP 粘包”,依然能搜到很多信息。TCP粘包,大概是说服务器/客户端调用了多次 send 发送数据,但是客户端/服务器 recv 一次全都收到了。

粘包其实是一个误会,因为 TCP 传输的数据是抽象成“数据流”的,没有“数据包”的概念。TCP协议保证每次 send 都能收到,但是不会把每次 send 的内容区分开。实际上还可能出现 recv 一次,只能收到 send 的一部分数据。(比如 send 的数据的长度大于 recv 的缓冲区的大小)

解决“粘包”问题,是应用层的事情。比如 HTTP 协议就在头部规定了数据的长度。客户端/服务器在解析的时候,就要根据HTTP协议头部给出的长度,从TCP“数据流”中读取指定长度的数据。

WebSocket 协议也有类似的机制,每一帧数据的头部都储存了数据的长度。

TCP的这个特性在写程序的时候,还是挺麻烦的。

多线程?单线程?

TCP 协议常常要提到多线程。因为 recv 会阻塞当前线程,直到有数据到达才返回。简单粗暴的解决方法是,创建一条线程专门执行 recv,有数据到达就存到buffer里。主线程从buffer里取数据解析、处理。

但这就涉及到两条线程访问同一个变量的问题了,最终弄下来,也没有多么简单。

有没有办法在一条线程上既能recv,也能解析呢?

我找到了 select 模型。select 模型一般的教程都以服务端举例,其实用到客户端上也是可以的。

本质地来说,select 就是监控文件描述符状态的函数(unix的一个设计哲学,一切皆文件,socket 也是一个文件描述符)。

select 函数能同时监控多个文件描述符,如果文件变得“可读”、“可写”或者“有错误”,那么它就会返回。如果已知某个 socket 变得可读,这时候再执行 recv 就一定有数据啦。(当然也可能读到长度为0的数据,意思是socket被关闭了)

最重要的是,select 是可以设置超时时间的。用select监控一会socket,再解析一会buffer,把这个“一会儿”弄得很短,就做到一边监控,一边解析的效果了。

使用单线程,让我的 WebSocketClient 变得非常简洁,单线程比多线程还是简单一点的。

很多 WebSocket 库之所以那么复杂,就是因为引入了 asio 之类的异步库。当然还有因为涉及到安全,引入了 openssl。

跨平台

用到的System API都是简单而古老的API,做到跨平台还是比较轻松的。Windows 的 Win32 API看着头大,时不时来个全大写的宏,我英语又不好,要慢慢读才看得懂。

虽然如此,但我还是把 Linux 的 API 往 Windows 上靠近。把 Linux 缺失的宏补上,从而做到跨平台。

Windows 的头文件引入了特别多的宏。为了避免库的使用者被这些宏污染,必须想办法把头文件移动到 .cpp 的源文件里。这里用到了一个C++编程的技巧,PIMPL Idiom。原理就是利用前置声明,即在头文件里声明一个 struct,在源文件里定义这个struct。前置声明的struct是不完整类型,C/C++允许不完整类型的指针存在。