前言
声明:本文不包含具体的C++实现代码,仅提供实现思路
书接上回,在C++中调用WinSock2向服务器发送http请求后进入接收状态,如果只接收一次,一来无法确定服务端发送的数据的长度,二来没办法处理服务端多次发送的情况。前一个问题可以通过分配过量的内存空间来实现,但不够灵活且容易造成资源浪费;后者可以通过循环接收来实现,但由于服务器的特性,比如koa.js,nginx等http服务器,其不会主动关闭socket,因此何时退出循环成为了个问题。尝试过在服务端主动关闭socket来使客户端退出接收循环,但是经过了一层代理后,代理服务器却把这个socket连接保持着,还是关不掉,因此最后只好放弃。
一番琢磨,发现解决问题的途径并不是让服务器关闭socket,而是由客户端主动关闭socket,这是由http服务器的功能特性以及网络传输的特性决定的。当客户端向服务端发送请求时,如果请求内容过大,无法一次性发送完,就需要在建立连接后分次发送,或者发送的时候受到网络速度影响,导致每次服务端只能收到一部分,需要分好几次才能接受完整;同理,服务端响应时,如果处理速度较慢或者网络环境影响较大,也有可能分好几次进行发送。这就意味着,无论是发送还是接收,都要循环进行。而服务端往往是更受伤的那一个,如果自己提前关闭socket导致客户端发送数据发送一半发不了了,背锅的肯定是自己,为了适应各种各样的客户端、请求、网络环境,在客户端和服务端建立socket通信之后,索性就不关闭socket,只管接收客户端发送的消息并进行响应,关不关就交给客户端来决定吧。
因此,现在常用的几个http服务器,基本上都不会主动关闭socket,客户端发送请求后指望着服务端关闭socket来使自己退出循环,做梦。
但是问题总得解决,服务器还是希望发送完数据后socket可以关闭,不然老给人吊着不霍霍心里也怪不是滋味。所以这里面就产生了一套标准,收录在RFC(Request for Comments,是互联网工程任务组IETF和互联网协会ISOC发布的一系列互联网相关信息的备忘录文档)中,http传输中最常用的标准协议是RFC 7230,这里面规定了http报文的格式、头信息格式、内容限制、传输方式、终止条件等内容,好让客户端和服务端在使用socket通信时可以正确提取传输内容、及时退出连接,保证资源的合理利用等。
HTTP报文格式
HTTP报文可以简单分为请求报文和响应报文。顾名思义,请求报文就是客户端发给服务端的报文,响应报文反之。
请求报文
一个请求报文的示例如下,每一行的分隔符都是\r\n
(CRLF)。
1 | POST /hello HTTP/1.1 |
首行为请求信息,第一个字段是请求类别,GET/POST/PUT/DELETE等,第二个是路由,第三个是HTTP协议标准;
紧跟首行的是请求头信息,通过冒号(colon,:
)分隔键值对;
最后一部分是请求体,与请求头隔了两个空行,请求体的长度由请求头中的字段Content-Length
指定。
响应报文
响应报文来自服务端,示例如下,每一行的分隔符同样是\r\n
(CRLF)。
1 | HTTP/1.1 200 OK |
首行为响应信息,第一个字段是协议,第二个字段是状态码,第三个字段可能存在也可能不存在,是一个对响应状态进行描述的描述符,一般是一个简短的字符串;
紧跟着首行的为响应头,同样是用冒号隔开的键值对;
最后一部分响应体用来存放数据,与响应头隔了两个空行,存放的数据有两种,上面例子展示的数据为已知长度的数据,通过响应头的Content-Length
指定长度;而第二种数据是chunked
,也就是分块发送,这种数据格式无法预先告知数据长度,但是会在响应头通过字段告知客户端,每一个数据块按照一定的格式存放当前块的长度和内容,客户端接收后再将所有的块拼起来,终止条件是收到一个空块。一个示例如下:
1 | HTTP/1.1 200 OK |
以上的写法并不严谨,但能说明问题。当在响应头中收到字段Transfer-Encoding: chunked
时,表明这份报文的响应数据将会通过块的方式发送,因此采取对块进行解析的方式接收数据。每一个数据块的格式为:[size]\r\n[data]\r\n
,首行为长度,次行为数据,收到块之后,根据长度提取数据,再将数据逐个拼起来,当收到0\r\n\r\n
时,表明数据块收集完成,可以退出接收状态。
块方式传输数据偶尔会用到,用于一些存在时延的处理操作,例如,上方报文的服务端响应函数为:
1 | const Koa = require('koa'); |
接收数据的思路
响应报文的第一行是响应状态,紧跟着几行是头信息,再接着是数据。头信息和数据之间有一个空行(两个\r\n
),可以利用这个特点来帮助解析。需要注意的是,接收的内容受缓冲区大小、网络传输速度等因素限制,最好的情况是一次性全部收完,但也有可能一次连一行都接收不完,因此,应该用一个无限循环不断接收数据,然后通过已接收到的数据,判断何时退出,就是一个类似于异步的思想。在C++中使用异步最好的办法就是用多个变量和状态来进行控制。
创建一个变量专门收集数据,recvData
,从网络中收到数据后先添加到这个变量中,然后再去解析;用一个变量,state
,记录当前解析的进度,首先从HTTP报文的状态行开始,如果还没达到行尾,就继续接收(循环);到达行尾后,解析这一行的内容,解析完成后,从recvData中删除这一行,把state
设置为解析header
。同理,解析header
也是以行为单位,没遇到行尾就继续接收,解析完一行就删一行,停止解析header
的标志是读到了一个空行,解析完header
后是解析body
。
一般情况下,body
的长度由header
中的Content-Length
指定,可以根据这个长度来判断数据是否接收完;如果采用块传输,头信息中应包含如下键值对:Transfer-Encoding:chunked
,然后分块传输,直到收到终止块。
退出循环的条件:
- 接受完数据;
- 接收完
Content-Length
指定长度的数据 - 接收完最后一个数据块
- 接收完
- 等待超时;