LiteHub之请求与响应报文分析
Qingyh Lv2

这一篇文章是基于项目LITEHUB分析的关于HTTP的请求和响应篇章,后续将仔细分析这个项目所涉及到的知识点。
image
上图是使用wireshark抓包分析的HTTP刷新网页的请求以及返回的响应报文格式。接下来我们就详细分析:

请求报文

理论部分

请求报文主要由请求行、请求头、空行、请求头构成。
image
请求行包括一下字段:常见的方法包括GET(请求信息)、POST(提交数据,表单)方法等,资源路径(请求资源的URI路径)、HTTP的版本(HTTP1.1/HTTP2.0)
请求头的字段较多,常使用的包含以下几个:
● Host:请求的服务器的域名。
● Accept:客户端能够处理的媒体类型。
● Accept-Encoding:客户端能够解码的内容编码。
● Authorization:用于认证的凭证信息,比如token数据。
● Content-Length:请求体的长度。
● Content-Type:请求体的媒体类型。
● Cookie:存储在客户端的cookie数据,在我的项目中,也是通过存在cookie字段来区别不同用户。

空行是请求头部和请求主体之间的空行,用于分隔请求头部和请求主体。
而请求体通常用于 POST 和 PUT 请求,包含发送给服务器的数据。

wireshark抓包分析

image

  • 请求行:这里使用请求方法为GET,请求的路径为/resource/list(服务器通过路由转发确定其实际请求的路径资源,后面会分析),并且使用的版本号是HTTP/1.1.
  • 请求头:是以一系列的键值对组成的。如上图包括Host:192.168.83.128(表示请求的服务器的域名,这个字段是为了区分在一个服务器上存在多个地址的问题,如百度和edge都在一台服务器的不同网卡上服务,通过这个字段可以区分);Cookie: sessionId=ef4130be9ef3c2b7264455b2a201a1b1(保持访问的一个状态),其余字段感兴趣的话可以自己去了解
  • 空行: \r\n,用于分割请求头和请求体;
  • 请求体:这里为空;

代码解析

上面我们已经分析了请求体的格式,接下来我们就根据格式,通过代码解析请求报文。

HttpContext.h中定义解析的状态,只有当前状态成功解析完成后,才可以转到下一个状态;

1
2
3
4
5
6
7
enum HttpRequestParseState
{
kExpectRequestLine, // 解析请求行
kExpectHeaders, // 解析请求头
kExpectBody, // 解析请求体
kGotAll, // 解析完成
};

HttpContext.c中定义两个标志,用于标志每个状态解析是否出错以及是否解析完成(如果请求体有值,就需要解析到请求体;如果请求体为空,则解析到空行就行)。

1
2
bool ok = true; // 解析每行请求格式是否正确
bool hasMore = true; //是否还需要解析

1.请求行解析

当前的请求行是这样:GET /resource/list HTTP/1.1\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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
 if (state_ == kExpectRequestLine)
{
//找到\r\n
const char *crlf = buf->findCRLF(); // 注意这个返回值边界可能有错
if (crlf)
{
//(buf->peek(), crlf)区间;其实就对应GET /resource/list HTTP/1.1
ok = processRequestLine(buf->peek(), crlf);
if (ok)
{
request_.setReceiveTime(receiveTime);
//向后移动两个字符,跳过\r\n
buf->retrieveUntil(crlf + 2);
//并设置状态为kExpectHeaders,用于解析请求头
state_ = kExpectHeaders;
}
else
{
//如果解析出错,停止解析
hasMore = false;
}
}
else
{
//如果请求行没有找到\r\n,说明请求行格式错误,停止解析
hasMore = false;
}
}


//定义专门用来解析请求行的函数
bool HttpContext::processRequestLine(const char *begin, const char *end)
{
bool succeed = false;
const char *start = begin;
//以这个为例,GET /resource/list HTTP/1.1
const char *space = std::find(start, end, ' ');//找到第一个空格
if (space != end && request_.setMethod(start, space))//将GET截取出来并存储到request_的请求方法字段
{
start = space + 1;
space = std::find(start, end, ' ');//继续找下一个空格
if (space != end)
{
const char *argumentStart = std::find(start, space, '?');
if (argumentStart != space) // 请求带参数
{
//GET /search?q=example HTTP/1.1,类似这种会被解析为路径和对应参数
request_.setPath(start, argumentStart); // 注意这些返回值边界
request_.setQueryParameters(argumentStart + 1, space);
}
else // 请求不带参数
{
//将/resource/list添加到请求路径中
request_.setPath(start, space);
}

start = space + 1;
//"HTTP/1.1"或者"HTTP/1.0"都是8个字节
succeed = ((end - start == 8) && std::equal(start, end - 1, "HTTP/1."));
if (succeed)
{ //设置HTTP版本协议号
if (*(end - 1) == '1')
{
request_.setVersion("HTTP/1.1");
}
else if (*(end - 1) == '0')
{
request_.setVersion("HTTP/1.0");
}
else
{
succeed = false;
}
}
}
}
return succeed;
}

// 这是从问号后面分割参数
void HttpRequest::setQueryParameters(const char *start, const char *end)
{
std::string argumentStr(start, end);
std::string::size_type pos = 0;
std::string::size_type prev = 0;

// 按 & 分割多个参数
while ((pos = argumentStr.find('&', prev)) != std::string::npos)
{
std::string pair = argumentStr.substr(prev, pos - prev);
std::string::size_type equalPos = pair.find('=');

if (equalPos != std::string::npos)
{
std::string key = pair.substr(0, equalPos);
std::string value = pair.substr(equalPos + 1);
queryParameters_[key] = value;
}

prev = pos + 1;
}

// 处理最后一个参数
std::string lastPair = argumentStr.substr(prev);
std::string::size_type equalPos = lastPair.find('=');
if (equalPos != std::string::npos)
{
std::string key = lastPair.substr(0, equalPos);
std::string value = lastPair.substr(equalPos + 1);
queryParameters_[key] = value;
}
}

根据上述函数,我们就解析出来了请求行的三个字段,请求方法、url路径、以及Http的版本号。接下来就开始解析请求头了。

2.请求头解析

根据上述分析,请求头是以键值对形式存储的(每一行存储一个键值对),并且键和值的分割是以“:”分割,基于这个分析,请求头就比较好解析了。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
 else if (state_ == kExpectHeaders)
{
const char *crlf = buf->findCRLF();
if (crlf)
{
//找到':'
const char *colon = std::find(buf->peek(), crlf, ':');
if (colon < crlf)
{
request_.addHeader(buf->peek(), colon, crlf);
}
else if (buf->peek() == crlf)
{
// 如果找到空行,则结束Header
// 根据请求方法和Content-Length判断是否需要继续读取body
if (request_.method() == HttpRequest::kPost ||
request_.method() == HttpRequest::kPut)
{
std::string contentLength = request_.getHeader("Content-Length");
if (!contentLength.empty())
{
request_.setContentLength(std::stoi(contentLength));
if (request_.contentLength() > 0)
{
state_ = kExpectBody;
}
else
{
//如果不用解析请求体,则直接结束
state_ = kGotAll;
hasMore = false;
}
}
else
{
// POST/PUT 请求没有 Content-Length,是HTTP语法错误
ok = false;
hasMore = false;
}
}
else
{
// GET/HEAD/DELETE 等方法直接完成(没有请求体)
state_ = kGotAll;
hasMore = false;
}
}
else
{
ok = false; // Header行格式错误
hasMore = false;
}
//跳过\r\n
buf->retrieveUntil(crlf + 2); // 开始读指针指向下一行数据
}
else
{
hasMore = false;
}
}

void HttpRequest::addHeader(const char *start, const char *colon, const char *end)
{
//':'前的值作为key
std::string key(start, colon);
++colon;
while (colon < end && isspace(*colon))
{
++colon;
}
//':'后的值作为value
std::string value(colon, end);
while (!value.empty() && isspace(value[value.size() - 1])) // 消除尾部空格
{
value.resize(value.size() - 1);
}
//存储key-valua键值对
headers_[key] = value;
}

在找到空行后,需要根据请求方法(判断是否是POST或者PUT方法)和Content-Length判断是否需要继续读取body;如果不需要,则直接退出解析;如果请求体有数据,开始解析请求体。

3.请求体的解析

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
        else if (state_ == kExpectBody)
{

// 检查缓冲区中是否有足够的数据
if (buf->readableBytes() < request_.contentLength())
{
hasMore = false; // 数据不完整,等待更多数据
return true;
}
const std::string &contentType = request_.getHeader("Content-Type");
if (contentType.find("multipart/form-data") != std::string::npos) //multipart/form-data表示支持多种复制格式的数据上传,如txt文件,mp4,rar等;这也是实现各种数据格式上传文件的解析
{

ok=parseMultipartData(buf);
request_.set_parseMultipartData_state(ok);
LOG_INFO<<"multipart/form-data解析"<<((ok)?"成功":"失败");

state_ = kGotAll;
hasMore = false;
}
else
{
//如果只是普通的数据,直接根据contentLength读入到body中即可。
// 只读取 Content-Length 指定的长度
std::string body(buf->peek(), buf->peek() + request_.contentLength());
request_.setBody(body);

// 准确移动读指针
buf->retrieve(request_.contentLength());

state_ = kGotAll;
hasMore = false;
}
}



bool HttpContext::parseMultipartData(Buffer *buf)
{
// 1. 找到并跳过第一个 boundary(如 ------WebKitFormBoundaryxxxx)
const char* crlf = buf->findCRLF();
if (!crlf) return false; // 不完整
std::string boundary(buf->peek(),crlf);
LOG_INFO<<"boundary:"<<boundary;
buf->retrieveUntil(crlf + 2);


// // 2. 解析 Content-Disposition 行
crlf = buf->findCRLF();
if (!crlf) return false; // 不完整
std::string dispositionLine(buf->peek(), crlf);
buf->retrieveUntil(crlf + 2);

std::string Content_Disposition;
std::string name;
std::string filename;

size_t Content_Pos = dispositionLine.find("Content-Disposition:");
if (Content_Pos != std::string::npos)
{
Content_Pos += 20;
size_t ContentEnd = dispositionLine.find('"', Content_Pos);
if (ContentEnd != std::string::npos)
Content_Disposition.assign(dispositionLine.data() + Content_Pos, ContentEnd - Content_Pos);
}

LOG_INFO<<"Content_Disposition:"<<Content_Disposition;
size_t namePos = dispositionLine.find("name=\"");
if (namePos != std::string::npos)
{
namePos += 6;
size_t nameEnd = dispositionLine.find('"', namePos);
if (nameEnd != std::string::npos)
name.assign(dispositionLine.data() + namePos, nameEnd - namePos);
}

LOG_INFO<<"name:"<<name;
size_t filePos = dispositionLine.find("filename=\"");
if (filePos != std::string::npos)
{
filePos += 10;
size_t fileEnd = dispositionLine.find('"', filePos);
if (fileEnd != std::string::npos)
filename.assign(dispositionLine.data() + filePos, fileEnd - filePos);
}
LOG_INFO<<"filename:"<<filename;
// 3. 跳过 Content-Type 行
crlf = buf->findCRLF();
if (!crlf) return false;
buf->retrieveUntil(crlf + 2);


// 4. 跳过空行(说明下一个就是正文)
crlf = buf->findCRLF();
if (!crlf) return false;
buf->retrieveUntil(crlf + 2);


// 5. 写入文件,直到遇到下一次 boundary
const char* fileStart = buf->peek(); // 文件内容起点
const char* fileEnd = std::search(
fileStart, buf->peek()+ buf->readableBytes(),
boundary.c_str(), boundary.c_str() + boundary.size()
);

if (fileEnd == buf->beginWrite()) {
// 未找到结束 boundary,暂时数据不足
return false;
}
LOG_INFO<<"开始创建文件咯";
request_.set_filename(filename);

if (filename.find(".avi")!= std::string::npos ||filename.find(".mp4")!= std::string::npos||filename.find(".mkv")!= std::string::npos)
{
FileUtil writer("/root/uploads/videos/" + filename);
writer.writeBinary(fileStart, fileEnd - fileStart);
LOG_INFO<<"这是一个视频"<<filename;
}
else
{
FileUtil writer("/root/uploads/" + filename);
writer.writeBinary(fileStart, fileEnd - fileStart);
}



LOG_INFO<<"创建完成";

// 6. 移动 buffer 指针,跳过文件数据和 boundary和\r\n
buf->retrieveUntil(fileEnd+2); // 跳过文件数据
//这里移动了,但是貌似还剩下一点东西,我测试了并没有发现问题,就不管啦!!!
return true;

}

上面的抓包过程中没有文件上传的解析,这里我上传了一个txt文件,并抓包分析。

image
上图的红框部分就是我此次上传的txt文件的请求体部分的抓包。以下是分析:

  1. 边界值:——WebKitFormBoundaryLkDgzlyaqa0LgduN(细心的同学看到了,在请求体中的最开始部分和结尾部分都是以这个值作为分界线的,而在这直接的就是具体的内容)
  2. Content-Disposition: form-data; name=”file”; filename=”test.txt”;form-data表示是一个表单数据;name字段对应表单中的字段名;filename是客户端上传的一个原始文件名。这个字段是用于告知服务器如何处理这部分数据。
  3. Content-Type: text/plain\r\n\r\n。这个是声明这部分数据的媒体类型。(text/plain表示内容是无格式的纯文本)
    image
    这部分就是实际的传输内容。
    传输如视频或者压缩文件为01二进制流。所以对于上传不同媒体数据的思想是,通过Content-Disposition中的filename字段,获取原数据的文件名,保存为相应的文件类型,并将实际的内容放到对应的文件中。

视频上传的抓包截图:
image

响应报文

理论部分

image
HTTP响应报文是服务器向客户端返回的数据格式,用于传达服务器对客户端请求的处理结果以及相关的数据。一个标准的HTTP响应报文通常包含状态行、响应头、空行、响应体。
状态行包含HTTP版本、状态码和状态消息。例如:HTTP/1.1 200 OK,404表示没有找到,429表示请求连接过多,我的羡慕中使用令牌桶进行限流的时候,如果某一时候请求连接过多,就返回429状态码
响应头部也是以键值对的形式提供的额外信息,类似于请求头部,用于告知客户端有关响应的详细信息。一些常见的响应头部字段包括:
● Content-Type:指定响应主体的媒体类型。
● Access-Control-Allow-Origin: 跨源资源共享(CORS)策略,指示哪些域可以访问资源。
● Content-Length:指定响应主体的长度(字节数)。
● Expires: 响应的过期时间。
● ETag: 响应体的实体标签,用于缓存和条件请求。
● Last-Modified: 资源最后被修改的日期和时间。
● Location:在重定向时指定新的资源位置。
● Set-Cookie:在响应中设置Cookie。

空行(Empty Line)在响应头和响应体之间,表示响应头的结束。而响应体是服务端实际传输的数据,可以是文本、HTML页面、图片、视频等,也可能为空。

wireshark抓包分析

image

  1. 状态行返回 HTTP/1.1(协议版本),200(状态码),ok(状态码对于的短语)
  2. 响应头部,也是以键值对形式保存值的。Connection: Keep-Alive(表示这是一个长连接,即可以在这个连接上多次请求与响应);Access-Control-Allow-Origin: *(支持跨域访问源,这里的‘*’表示所有源都可访问);Content-Encoding: gzip(表示使用了gzip编码);Content-Length: 325(这个字段表示响应体的数据大小为325字节);Content-Type: application/json(表示响应体的格式为json)
  3. 响应体可以是多种格式,在这里是json格式,见下图
    image
    这里的一个json文件中有3个值,filename,uploadtime,username;多个json文件构成了一个Array。

代码实现

响应字段是由浏览器解析的,这里我们不需要实现解析的部分。响应是由服务器生成并返回的,所以在这里需要实现HTTP响应报文的生成。
请看代码:

1.设置状态行

1
2
3
4
5
6
7
8
9
void HttpResponse::setStatusLine(const std::string& version,
HttpStatusCode statusCode,
const std::string& statusMessage)
{
httpVersion_ = version; //设置HTTP的版本号
statusCode_ = statusCode;//设置响应状态码
statusMessage_ = statusMessage;//设置响应消息
}

2.生成响应体

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
void HttpResponse::appendToBuffer(muduo::net::Buffer* outputBuf) const
{
// HttpResponse封装的信息格式化输出
char buf[32];
// 为什么不把状态信息放入格式化字符串中,因为状态信息有长有短,不方便定义一个固定大小的内存存储
snprintf(buf, sizeof buf, "%s %d ", httpVersion_.c_str(), statusCode_);

outputBuf->append(buf);
outputBuf->append(statusMessage_);
outputBuf->append("\r\n");

//是否设置长连接
if (closeConnection_)
{
outputBuf->append("Connection: close\r\n");
}
else
{
outputBuf->append("Connection: Keep-Alive\r\n");
}

for (const auto& header : headers_)
{ // 为什么这里不用格式化字符串?因为key和value的长度不定
outputBuf->append(header.first);
outputBuf->append(": ");
outputBuf->append(header.second);
outputBuf->append("\r\n");
}
outputBuf->append("\r\n"); //添加空行

outputBuf->append(body_); //添加响应体
}

格式化响应之后,通过网络发送响应消息,浏览器解析响应并渲染,一次HTTP请求就完成了。

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 31k