[C/C++后端开发学习] 8 tcp服务器支持websocket协议

本文详细介绍了WebSocket协议的工作流程,包括握手过程、通信协议和结束过程,并展示了如何在C/C++后端开发中实现WebSocket服务器,涵盖了状态机、代码结构和测试方法。
摘要由CSDN通过智能技术生成

背景

尽管HTTP协议在WEB上已广泛应用,但它是一个无状态、单向通信的协议,那么对于一些需要实时刷新页面数据的场合,基于HTTP实现起来就会很尴尬:前端需要不断地发起HTTP请求连接到服务端去获取最新的数据。这造成了一些问题:

  • 大量HTTP请求泛滥造成服务器资源的浪费;
  • 对于前端脚本来说也造成了额外的资源开销;

于是,我们自然而然就会想到,能不能单独建立一个TCP长连接在前后端之间实现数据的双向通信,以便于服务端可以主动地将数据推送给前端。于是 websocket 就诞生了。

websocket

实际上,websocket在连接的建立阶段借用了HTTP协议,这个过程被称为“握手(handshake)”。设计者这么做是非常明智的,其好处在于:可以利用HTTP协议已有的一些组件(代理、过滤器、认证机制),同时可以复用HTTP常使用的80或443端口,等等。基于HTTP建立连接后,websocket就可以自由地通信了。

也是基于此,我们一般将websocket协议的工作过程分为两部分:1)握手和 2)通信。此外还有一个结束过程,但一般可认为其是通信过程的一部分。后面会分开进行解析。

应用场景

  • 网页聊天,即时通信
  • 弹幕
  • 股票数据实时刷新

案例:CSDN上微信扫描二维码登录。扫描之后之所以前端页面能主动实现跳转,就是因为服务器通过websocket协议主动推送数据给前端。

websocket工作流程

握手过程(Opening Handshake)

当客户端向服务端发起握手时,其HTTP报文一般格式如下所示:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务端接收到请求后,其响应报文格式一般如下:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

其中,Sec-WebSocket-Protocol指出客户端所支持的基于websocket的其他用户协议,服务端在响应报文中选择其中一种,比如不同的页面需要不同的用户协议。我们重点关注请求报文中的Sec-WebSocket-Key字段和响应报文中的Sec-WebSocket-Accept字段,从字面上来看,这两个字段主要与认证有关。它们有助于确认服务端能够正确地支持websocket协议,同时也确保了客户端发起的这个请求是websocket的握手请求而非普通的HTTP请求。

实际上,Sec-WebSocket-Key是客户端产生的一个随机字串,服务端接收到Sec-WebSocket-Key后,会做两步处理:

  • 1 websocket协议定义了一个Globally Unique Identifier (GUID): 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,服务端将该GUID拼接到Sec-WebSocket-Key内容的后面,如:dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CAC5AB0DC85B11
  • 2 对拼接后的字符串做一次SHA-1 hash运算(160bit, 20 bytes),再对输出的结果做一次base64编码,即可得到我们在响应报文中看到的Sec-WebSocket-Accept的内容s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。将其返回给客户端。

客户端接收到响应报文后,对Sec-WebSocket-Key做一次同样的计算,然后比对与服务端结果是否一致,一致则握手过程完成,服务端证明了自己是一个合格的websocket服务器。注意响应报文首部是HTTP/1.1 101 Switching Protocols

之后,二者就抛开HTTP协议开心地进入了websocket通信环节。

通信协议

websocket通过发送一系列的数据帧来进行通信,帧格式如下图所示:
在这里插入图片描述

  • FIN: 1 bit
    指示当前帧是否为一段完整数据的最后一帧,为0表示后面还有数据,为1表示这是最后一帧;注意FIN是在最高位。

  • RSV1, RSV2, RSV3: 1 bit each
    保留字段,必须为0,除非经过协商。

  • opcode: 4 bits
    操作码,指示payload数据的类型。

    * %x0 denotes a continuation frame
    * %x1 denotes a text frame
    * %x2 denotes a binary frame
    * %x3-7 are reserved for further non-control frames
    * %x8 denotes a connection close
    * %x9 denotes a ping
    * %xA denotes a pong
    * %xB-F are reserved for further control frames

  • MASK: 1 bit
    指示payload数据是否经过掩码处理。如果为1,那么Masking-key就是掩码。协议中规定客户端发送到服务端的数据都要经过掩码处理。这个掩码处理从严格意义上来说,肯定不能起到加密的作用,但我的理解是,别人抓到你的包时,如果其中没有明文,只有二进制的数据,别人可能甚至都不知道这是一个websocket数据帧,从这个角度来想还是有一定意义。

  • Payload len: 7 bits, 7+16 bits, or 7+64 bits
    payload的长度是websocket协议中一个很灵活的设计。如果数据长度小于126字节(0~125),那么Payload len上就直接存储这个长度值,不需要后面的Extended payload length,直接就是Masking-key;如果数据长度大于126字节,则根据需要:1)将Payload len设为126,那么之后的2个字节用于表示数据长度;2)将Payload len设为127,那么之后的8个字节用于表示数据长度。
    一定注意了:websocket采用的网络字节序(大端字节序)!Payload len>=126时,接收到Payload len记得用ntohs进行转换,发送前用htons进行转换后再送入Payload len

  • Masking-key: 0 or 4 bytes
    如果MASK置位1,则这是一个4字节的掩码,否则该数据不存在。掩码数据长度不包含在payload的长度中。

  • Payload Data
    所有的数据。其中文档中还会提到扩展数据,那是双方自行协商的数据部分,可以不理会。

此外,需要说明一下掩码的工作方式:将payload中的数据与掩码数据做异或运算,客户端对数据做异或处理后,服务端使用相同的掩码再做一遍异或处理,就可以恢复数据。这里是利用了异或运算的特性:A异或B得到C,C再异或B可以恢复为A。这里边有4个掩码字节,每一次运算其实是轮流取这4个字节中的一个去与payload数据做异或,看代码会更直观一些:

void umask(char *data,int len,char *mask) 
{
       
	int i;    
	for (i = 0;i < len;i ++)        
		*(data+i) ^= mask[i%4];
}

结束(Closing Handshake)

结束过程比握手要简单得多。两边均可以发起结束过程,只需要发送一个数据帧其opcode为 0x8 就行了,称为结束帧。结束帧也能包含数据,可以用于提示为什么结束。关于结束帧的数据,websocket_rfc6455文档中有这么一点需要指出:

The Close frame MAY contain a body …
… If there is a body, the first two bytes of the body MUST be a 2-byte unsigned integer (in network byte order) representing a status code

… the client SHOULD wait for the server to close the connection but MAY close the connection … if it has not received a TCP Close from the server in a reasonable time period.

大致意思就是说,发送结束帧是,如果要带payload,数据开头的两字节需要是一个错误码(网络字节序形式存储),其具体的错误码定义还需看原文档。其次,客户端发出结束帧后,会等待服务端关闭连接,除非超时客户端才会先关闭连接。

当某一端发出结束帧后,就不应该再发送任何数据。另一端接收到结束帧后,将剩下的数据发完,然后发送一个结束帧的响应。之后,两边就可以关闭连接了。如果两端同时发起了结束帧,则双方都需要响应结束帧后再关闭连接。

既然TCP本身有四次挥手,为什么websocket还要单独搞一出结束帧?websocket_rfc6455文档中是这样解释的:

The closing handshake is intended to complement the TCP closing handshake (FIN/ACK), on the basis that the TCP closing handshake is not always reliable end-to-end, especially in the presence of intercepting proxies and other intermediaries.
_
By sending a Close frame and waiting for a Close frame in response, certain cases are avoided where data may be unnecessarily lost. For instance, on some platforms, if a socket is closed with data in the receive queue, a RST packet is sent, which will then cause recv() to fail for the party that received the RST, even if there was data waiting to be read.

实现

本代码在上一篇博客《tcp服务器的epoll实现以及Reactor模型》的代码基础上增加对websocket协议的支持。

服务端的状态机

初始状态 - 建立好socket,尚未握手
握手状态 - 客户端发起握手的报文并正确响应
通信状态 - 握手过程结束后就会一直处于通信状态,按照既定地协议进行数据收发即可
结束状态 - 客户端发起结束帧,服务端返回结束帧后主动关闭连接

代码实现

结构定义
/* Websocket相关定义 */

enum WS_STATUS  // 状态
{
   
    WS_INIT = 0,
    WS_HANDSHAKE,
    WS_DATATRANSFER,
    WS_END
};

struct ws_opHeader
{
   
    unsigned char opcode : 4,
                  RSV123 : 3,
                  FIN    : 1;

    unsigned char payloadLen : 7,
                  MASK       : 1;
}__attribute__ ((packed));  // __attribute__ ((packed)) 的作用就是告诉编译器取消结构在编译过程中的优化对齐

struct ws_dataHeader126
{
   
    unsigned short payloadLen;
}__attribute__ ((packed));

struct ws_dataHeader127
{
   
    unsigned long long payloadLen;
}__attribute__ ((packed));

#define GUID ("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")

/* Websocket相关定义 END */

#define MAX_BUFFER_SIZE 1024
struct sockitem
{
   
	int sockfd;
	int (*callback)(int fd, int events, void *arg);
    int epfd;

    char recvbuffer[MAX_BUFFER_SIZE]; // 接收缓冲
	char sendbuffer[MAX_BUFFER_SIZE]; // 发送缓冲
    int recvlength; // 接收缓冲区中的数据长度
    int sendlength; // 发送缓冲区中的数据长度

    int status;		// 增加status用于实现状态机
};

其中__attribute__ ((packed))的作用就是告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。这是GNU C 对 ANSI C 的扩展。

辅助函数
// 既可以加掩码也可以消除掩码
void umask(
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值