众所周知,HTTP在运输层是TCP协议,所以在socket编程中,一般是初始化socket,解析ip,connect,send,recv的步骤。
send请求头倒是容易,但在recv时就会发生问题。
recv需要传入一个接收大小,但在HTTP协议中,头部并没有包大小,所以这个大小一般作为缓冲区大小使用,例如传入1024 bytes这种。
HTTP丢包的问题
首先我以为通过判断recv返回值,可以得知包是否接收完全,但实践发现,这种方式会产生丢包。例如,包大小实际是2000 B,在第一次recv时,接收到了1024 B,程序继续接收,又接收到100 B,由于100<1024,程序认为已经接收完,就跳出循环了。但实际上还有876 B的数据没来得及进入socket缓冲区,程序就直接返回了。
解决这个问题,非常不优雅的做法就是在recv后加sleep,在网络畅通的情况下,稍微sleep几十毫秒,给数据拷进缓冲区留一点时间,就可以接收完全。但这个方法既不优雅也不可靠。网络拥堵时一样会失效。
实际上,recv的返回值只能说明本次从缓冲区取了多少字节,并不担保包已经结束,也不保证下一个分片什么时候到来。
解决方法
可以注意到HTTP的响应有两种格式,都是带有长度数据的。一种是Content-Length字段,后面直接带的就是正文长度。另一种是Transfer-Encoding: chunked,在这种格式下,正文部分为:
1a2<CRLF>
正文<CRLF>
51b<CRLF>
正文<CRLF>
0<CRLF>
<CRLF>
其中<CRLF>代表\r\n。格式就是一行16进制长度,跟着数据,需要注意的是数据后面这个<CRLF>是不算在长度里的。
由此就可以得出程序逻辑了:先recv数据,在数据里找第一次出现的\r\n\r\n(也就是响应头和正文的分界点),如果没找到就继续recv,找到就进行切分,把响应头和正文都切出来。再解析响应头,区分是Content-Length类型还是Transfer-Encoding类型。
如果是Content-Length类型,就计算上一步切分出的正文长度是否接收完全,没接收完就继续接收剩余数据。因为知道剩余多少字节,所以就不存在recv阻塞的问题了。
如果是Transfer-Encoding类型,就不断切分长度行和正文部分,根据长度行识别分块大小,直到长度行为0,确保最后的\r\n接收完毕,结束读取。
代码
上代码,这是调用部分:
main.cpp :
#include <string>
#include <iostream>
#include "MyInitSock.h"
#include "MyHTTP.h"
MyInitSock myInitSock;
using namespace std;
int main(int argc, char* argv[])
{
try
{
MyHTTP http;
string website = "www.163.com";
string ip = DnsParse(website);
//连接
http.Connect(ip, 80);
//设置请求头
http.request_header.host = website;
http.request_header.url = "/";
http.request_header.user_agent = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36";
//发送
http.SendGet();
//接收
http.RecvHTTP(10240);
//http.content = MyBase64::Unicode2GBK(MyBase64::UTF8toUnicode(http.content));
//显示结果
cout << http.response.raw_response_header;
cout << http.response.content;
}
catch (runtime_error e)
{
cout << e.what();
}
catch (invalid_argument e)
{
cout << e.what();
}
system("pause");
return 0;
}
MyInitSock代码就不上了,内容就是初始化socket。
MyHTTP.h :
#pragma once
#include "MySocket.h"
#include <unordered_map>
class MyHTTP :
public MySocket
{
public:
MyHTTP() :MySocket() {}
//请求头
struct RequestHeader
{
std::string host;
std::string url;
std::string user_agent;
};
RequestHeader request_header;
//响应数据
struct Response
{
std::string version, state_code, phrase;//版本 状态码 短语
std::unordered_map<std::string, std::string> header;//响应头
std::string raw_response_header;//响应头原始数据
std::string content;//正文
};
Response response;
//发送请求头
void SendGet();
//接收响应头
void RecvHTTP(int bufsize = 1024, int flags = 0) throw(std::runtime_error,std::invalid_argument);
};
MySocket的代码就不贴了,内容就是对socket中几个函数的简单封装,gethostname,connect这几个。
MyHTTP.cpp :
#include "MyHTTP.h"
#ifdef _DEBUG
#include <iostream>
#endif
using namespace std;
void MyHTTP::RecvHTTP(int bufsize, int flags) throw(std::runtime_error)
{
if (bufsize <= 0)
throw runtime_error("bufsize<=0");
char* buf = new(nothrow) char[bufsize + 1];
if (buf == nullptr)
throw runtime_error("memory is not enough.");
unique_ptr<char> up_buf(buf);
string& raw_head = response.raw_response_header;
string& content = response.content;
auto dic = response.header;
raw_head.clear();
content.clear();
while (1)
{
int rs = recv(m_socket, buf, bufsize, flags);
if (rs <= 0)
throw runtime_error("Recieve error. Error code:" + to_string(WSAGetLastError()));
buf[rs] = 0;
raw_head += buf;
//以两组CRLF切分请求头和内容
auto pos = raw_head.find("\r\n\r\n");
if (pos != string::npos)
{
content += raw_head.substr(pos + 4);//从CRLF后截取
raw_head.erase(raw_head.begin() + pos + 2, raw_head.end());//带上1组CRLF截取
break;
}
}
//识别第一行
//格式:版本 状态码 短语
size_t start = 0;
auto pos = raw_head.find("\r\n", start);
if (pos != string::npos)
{
stringstream ss(raw_head.substr(start, pos - start));
ss >> response.version >> response.state_code >> response.phrase;
start = pos + 2;
}
else
throw runtime_error("Can not parse the first line of request head:" + raw_head.substr(start, pos - start));
//解析请求头
dic.clear();
while (1)
{
auto pos = raw_head.find("\r\n", start);
//以CRLF切分
if (pos != string::npos)
{
string line = raw_head.substr(start, pos - start);//得到1行
//切分出key和value
auto pos_space = line.find(": ");
if (pos_space != string::npos)
{
string key = line.substr(0, pos_space);
string value = line.substr(pos_space + 2);
dic[key] = value;
}
else
{
throw runtime_error("Can not parse the line:" + line);
}
start = pos + 2;//设置起始点
}
else
{
break;
}
}
//接收正文
const char sz_content_length[] = "Content-Length";
auto it_content_length = dic.find(sz_content_length);
if (it_content_length != dic.end())
{
//length模式
int content_length = stoi(dic[sz_content_length]);
int remain = content_length - content.length();
if (remain < 0)//实际大小>标记大小
throw runtime_error("Field Content-Length is less than real content length.");
//接收剩余部分
while (remain)
{
int rs = recv(m_socket, buf, bufsize, flags);
if (rs <= 0)//若接收数据小于标记值,此处rs=-1
throw runtime_error("Recieve error. Error code:" + to_string(WSAGetLastError()));
buf[rs] = 0;
content += buf;
remain -= rs;
}
}
else
{
//chunked模式
const char sz_transfer_encoding[] = "Transfer-Encoding";
auto it_transfer_encoding = dic.find(sz_transfer_encoding);
if (it_transfer_encoding != dic.end() && dic[sz_transfer_encoding] == "chunked")
{
string temp = content;
content.clear();
int state = 0;
while (1)
{
auto pos = temp.find("\r\n");
if (pos != string::npos)
{
//此处保证 temp 以chunked大小开始
string s_len = temp.substr(0, pos);//得到大小
cout << s_len << endl;
int len = stoi(s_len,nullptr,16);
temp = temp.substr(pos + 2);//截掉大小,content现在是纯内容
int remain = len - temp.length();
if (remain <= -2)//缓冲数据超出大小截取点,直接进行截取
{
//第一处正式读取
content+=temp.substr(0, len);
temp=temp.substr(len+2);//越过结尾的\r\n
}
else
{
//接收不足部分
while (1)
{
int rs = recv(m_socket, buf, bufsize, flags);
if (rs <= 0)
throw runtime_error("Recieve error. Error code:" + to_string(WSAGetLastError()));
buf[rs] = 0;
temp += buf;
remain -= rs;
if (remain <= -2)//[len长度的chunk]后会额外跟一组\r\n,要越过\r\n需要至少多接收2B
{
//第二处正式读取
content += temp.substr(0, len);
temp = temp.substr(len + 2);//越过结尾的\r\n
break;
}
}
}
//接收完本分块
if (len == 0)//最后一个chunk以0\r\n\r\n结尾
{
//以下两行仅用于测试结尾分块是否逻辑正确
//正确的话此处socket缓冲区应无数据,recv应始终阻塞
//int rs = recv(m_socket, buf, bufsize, flags);
//if (rs <= 0)
// throw runtime_error("Recieve error. Error code:" + to_string(WSAGetLastError()));
break;
}
}
else
{
int rs = recv(m_socket, buf, bufsize, flags);
if (rs <= 0)
throw runtime_error("Recieve error. Error code:" + to_string(WSAGetLastError()));
buf[rs] = 0;
temp += buf;
}
}
}
}
}
void MyHTTP::SendGet()
{
if (request_header.url.empty())
request_header.url = "/";
string header = "GET " + request_header.url + " HTTP/1.1\r\n"
"Host: " + request_header.host + "\r\n"
"user-agent: " + request_header.user_agent + "\r\n"
"\r\n";
Send(header);
}
稍微麻烦点的逻辑是,响应头和内容部分处处都有断片的情况,就是在recv的过程中要不停进行切分和合并,耗费的逻辑比较多。如果每次都只recv 1B,就不存在这个问题了,但我测试过,每次recv 1B,效率低得惊人。
我已经尽量避免逻辑错误了,RecvHTTP中的bufsize可大可小,设置成10240可以,设置成1B也可以正常工作,就是效率很低。
效果
可以看到第1部分我把各个分块的长度输出出来了。第2部分输出的响应头。第3部分输出的正文。测试表明能够让recv不阻塞,尽量快地得到正文数据。
不知道成熟的库里是怎么实现的,是否也是我这种方法。感谢各位批评指正。