写了一个WebSocket客户端
写了一个WebSocket客户端
以前就接触过 tcp、udp 的编程,但是一直停留在 “Hello World” 的水平。这一次想写WebSocket客户端,是因为现有的库都非常巨大,但是我只需要一个能收消息的客户端。
之前一直用的是 easywsclient 这个库,只有一个头文件和一个源文件,简单好用。看源代码也没有很复杂,就自己做了一个“复刻版”(LightWebSocketClient)。
WebSocket 协议是有标准的,具体可以在 RFC6455 看到。协议的内容挺长的,但大可不必被吓倒,因为都很好理解。
1 | 0 1 2 3 |
写这个库花了两天时间,其中大部分时间都在研究怎么写 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++允许不完整类型的指针存在。
写了一个WebSocket客户端