0%

如何使用C++发送HTTP请求

  1. 前言
  2. HTTP报文格式
    1. 请求报文
    2. 响应报文
  3. 接收数据的思路

前言

声明:本文不包含具体的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
2
3
4
5
6
7
8
9
POST /hello HTTP/1.1
User-Agent: MyClient
Host: 127.0.0.1:6666
Accept:*/*
Content-Type:application/json
Charset:utf-8
Content-Length:13

{"a":1,"b":2}

首行为请求信息,第一个字段是请求类别,GET/POST/PUT/DELETE等,第二个是路由,第三个是HTTP协议标准;

紧跟首行的是请求头信息,通过冒号(colon,:)分隔键值对;

最后一部分是请求体,与请求头隔了两个空行,请求体的长度由请求头中的字段Content-Length指定。

响应报文

响应报文来自服务端,示例如下,每一行的分隔符同样是\r\n(CRLF)。

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.16.1
Date: Sat, 27 Nov 2021 11:20:39 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 5
Connection: keep-alive
X-Response-Time: 6ms

ok,ok

首行为响应信息,第一个字段是协议,第二个字段是状态码,第三个字段可能存在也可能不存在,是一个对响应状态进行描述的描述符,一般是一个简短的字符串;

紧跟着首行的为响应头,同样是用冒号隔开的键值对;

最后一部分响应体用来存放数据,与响应头隔了两个空行,存放的数据有两种,上面例子展示的数据为已知长度的数据,通过响应头的Content-Length指定长度;而第二种数据是chunked,也就是分块发送,这种数据格式无法预先告知数据长度,但是会在响应头通过字段告知客户端,每一个数据块按照一定的格式存放当前块的长度和内容,客户端接收后再将所有的块拼起来,终止条件是收到一个空块。一个示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Sat, 27 Nov 2021 11:55:15 GMT
Connection: keep-alive
Transfer-Encoding: chunked

21\r\n1-1-1-1-1-1-1-1-1-1-1-1-1-1-1-1-1\r\n

21\r\n2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2\r\n

21\r\n3-3-3-3-3-3-3-3-3-3-3-3-3-3-3-3-3\r\n

21\r\n4-4-4-4-4-4-4-4-4-4-4-4-4-4-4-4-4\r\n

21\r\n5-5-5-5-5-5-5-5-5-5-5-5-5-5-5-5-5\r\n

0\r\n\r\n

以上的写法并不严谨,但能说明问题。当在响应头中收到字段Transfer-Encoding: chunked时,表明这份报文的响应数据将会通过块的方式发送,因此采取对块进行解析的方式接收数据。每一个数据块的格式为:[size]\r\n[data]\r\n,首行为长度,次行为数据,收到块之后,根据长度提取数据,再将数据逐个拼起来,当收到0\r\n\r\n时,表明数据块收集完成,可以退出接收状态。

块方式传输数据偶尔会用到,用于一些存在时延的处理操作,例如,上方报文的服务端响应函数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
// 关闭自动响应,采用chunk方式发送
ctx.respond = false;
// Node.js Response
const { res } = ctx;
res.statusCode = 200;
res.setHeader('Content-Type', "text/plain");
let i = 0;
let total = 5;
while (i < total) {
console.log(++i);
await new Promise(resolve => {
setTimeout(() => {
res.write(`${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}-${i}`);
resolve();
}, 1000);
});
}
console.log('end');
res.end();

});

/**
* 监听
*/
const port = 8888;
app.listen(port, '0.0.0.0');
console.log(`app listen on: http://127.0.0.1:${port}`);

接收数据的思路

响应报文的第一行是响应状态,紧跟着几行是头信息,再接着是数据。头信息和数据之间有一个空行(两个\r\n),可以利用这个特点来帮助解析。需要注意的是,接收的内容受缓冲区大小、网络传输速度等因素限制,最好的情况是一次性全部收完,但也有可能一次连一行都接收不完,因此,应该用一个无限循环不断接收数据,然后通过已接收到的数据,判断何时退出,就是一个类似于异步的思想。在C++中使用异步最好的办法就是用多个变量和状态来进行控制。

创建一个变量专门收集数据,recvData,从网络中收到数据后先添加到这个变量中,然后再去解析;用一个变量,state,记录当前解析的进度,首先从HTTP报文的状态行开始,如果还没达到行尾,就继续接收(循环);到达行尾后,解析这一行的内容,解析完成后,从recvData中删除这一行,把state设置为解析header。同理,解析header也是以行为单位,没遇到行尾就继续接收,解析完一行就删一行,停止解析header的标志是读到了一个空行,解析完header后是解析body

一般情况下,body的长度由header中的Content-Length指定,可以根据这个长度来判断数据是否接收完;如果采用块传输,头信息中应包含如下键值对:Transfer-Encoding:chunked,然后分块传输,直到收到终止块。

退出循环的条件:

  1. 接受完数据;
    • 接收完Content-Length指定长度的数据
    • 接收完最后一个数据块
  2. 等待超时;