Content-LengthとTransfer-Encodingを用いたC++ソケットによるHTTPデータ受信の正確な実装

HTTPはトランスポート層でTCPプロトコルを使用するため、ソケットプログラミングでは通常、ソケットの初期化、IPの解決、接続、送信、受信という流れになります。

リクエストヘッダーの送信は比較的簡単ですが、受信処理では問題が発生します。

受信関数recvにはバッファサイズを指定する必要がありますが、HTTPプロトコルではヘッダーにデータ長を示すフィールドが存在しないため、通常は固定のバッファサイズ(例:1024バイト)を指定して処理を行います。

HTTPパケット欠損の問題

最初はrecvの戻り値を確認することでパケットが完全に受信できたかどうか判断できると考えていましたが、実際にはパケットが欠損する場合があります。例えば、実際のデータサイズが2000バイトであった場合、最初のrecvで1024バイト受信し、次に100バイトを受信したとします。このとき100<1024なので、プログラムは受信完了と判断しループを終了します。しかし、残りの876バイトはまだソケットバッファに到着していないため、データが失われてしまいます。

この問題に対処するため、不格好な方法としてrecv後にスリープを挿入する手法があります。ネットワークが安定している場合は数十ミリ秒程度のスリープでデータがバッファに移動する時間を作ることができます。しかし、これは非効率的かつ信頼性が低く、ネットワーク混雑時には機能しません。

recvの戻り値は、一度の呼び出しでバッファから読み取ったバイト数を示すだけであり、パケットの終端を保証するものではなく、次のパケットがいつ届くかも保証しません。

対策方法

HTTPレスポンスには2つの形式があり、いずれもデータ長情報を含んでいます。一つはContent-Lengthフィールドで、その直後に本文の長さが記述されています。もう一つはTransfer-Encoding: chunked形式で、この形式では本文は以下の構造になります:

1a2<CRLF> 本文<CRLF> 51b<CRLF> 本文<CRLF> 0<CRLF> <CRLF>

ここで<CRLF>は\r\nを意味します。構造は1行に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のコードは省略します。主にソケット初期化処理を担っています。

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関連の基本操作をラップしています。

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("メモリ不足です。");

	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("受信エラー。エラーコード:" + to_string(WSAGetLastError()));

		buf[rs] = 0;
		raw_head += buf;

		//CRLF2回でヘッダーと本文を分割
		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("リクエストヘッダーの最初の行を解析できません:" + 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行取得

			//キーと値を分割
			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("行を解析できません:" + 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("Content-Lengthフィールドの値が実際のコンテンツ長より小さい。");

		//残りを受信
		while (remain)
		{
			int rs = recv(m_socket, buf, bufsize, flags);
			if (rs <= 0)//受信データが記載値より少ない場合、rs=-1
				throw runtime_error("受信エラー。エラーコード:" + 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がchunkサイズで始まることを保証

					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("受信エラー。エラーコード:" + to_string(WSAGetLastError()));

							buf[rs] = 0;
							temp += buf;
							remain -= rs;

							if (remain <= -2)//[lenサイズのchunk]の後に追加の\r\nが続くため、少なくとも2B余分に受信が必要
							{
								//2回目の実際の読込
								content += temp.substr(0, len);
								temp = temp.substr(len + 2);//末尾の\r\nをスキップ
								break;
							}
						}
					}

					//現在のチャンクを処理完了
					if (len == 0)//最後のチャンクは0\r\n\r\nで終了
					{
						//以下は終了チャンクが正しいかどうかのテスト用
						//正しい場合、ソケットバッファにはデータがなく、recvは常にブロックする
						//int rs = recv(m_socket, buf, bufsize, flags);
						//if (rs <= 0)
						//	throw runtime_error("受信エラー。エラーコード:" + to_string(WSAGetLastError()));
						break;
					}
				}
				else
				{
					int rs = recv(m_socket, buf, bufsize, flags);
					if (rs <= 0)
						throw runtime_error("受信エラー。エラーコード:" + 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中に繰り返し分割・結合を行う必要がある点です。毎回1バイトずつrecvすると問題は発生しませんが、パフォーマンスが著しく低下します。

bufsizeの値は10240でも1でも動作可能ですが、前者の方が効率的です。

効果

図1では各チャンクの長さが出力されています。図2はレスポンスヘッダー、図3は本文が出力されます。recvがブロッキングせず、高速に本文を取得できることを確認できます。

実装ライブラリがどのように構成されているのかは不明ですが、おそらく同様のアプローチを採用しているでしょう。ご指摘いただければ幸いです。

タグ: HTTP socket C++ TCP Content-Length

9月23日 03:39 投稿