众所周知,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也可以正常工作,就是效率很低。

效果

PageRequest接收参数_PageRequest接收参数


PageRequest接收参数_#include_02


可以看到第1部分我把各个分块的长度输出出来了。第2部分输出的响应头。第3部分输出的正文。测试表明能够让recv不阻塞,尽量快地得到正文数据。

不知道成熟的库里是怎么实现的,是否也是我这种方法。感谢各位批评指正。