TinyWebserver学习(9)-HTTP
一、相关知识
1、有限状态机:
有限状态机(Finite State Machine, FSM)是一种用于描述对象在其生命周期内可能经历的不同状态及其状态转换规则的模型。它广泛应用于游戏开发、网络协议、词法解析、UI逻辑控制等领域。以下是C++中有限状态机的简介:
有限状态机的核心概念
- 状态(State)
对象可能处于的某种特定情况(如游戏中的角色“空闲”、“移动”、“攻击”)。 - 事件(Event)
触发状态转换的外部或内部条件(如用户输入“跳跃”、定时器超时)。 - 转换(Transition)
定义在特定事件发生时,从当前状态转移到目标状态的规则(如“空闲 → 移动”)。 - 动作(Action)
状态转换时执行的操作(如播放动画、发送网络包)。
2、http报文:
HTTP报文分为请求报文(浏览器端向服务器发送)和响应报文(服务器处理后返回给浏览器端)两种,每种报文必须按照特有格式生成,才能被浏览器端识别。
(1)请求报文:由请求行、请求头部、空行、请求数据四部分组成
- 请求行:用来说明请求类型(方法)、要访问的资源以及使用的http的版本
- 请求头部:用来说明服务器要使用的附加信息,由“名/值”对组成,每对一行,中间用冒号隔开
- 空行:请求头后面的空行是必须的,即使第四行请求数据为空行,第三行也必须是空行
- 请求数据:也叫主体,可以添加任意类型的数据
以下是通过抓包得到的http请求报文:
GET http://jsuacm.cn/ HTTP/1.1 //Get为请求方法,URL为请求资源,1.1为http版本
Host: jsuacm.cn
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3877.400 QQBrowser/10.8.4506.400
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9//”请求数据”(GET方式的请求一般不包含)

常用的头部信息汇总:
头部名称 | 类型 | 作用说明 | 示例值 |
---|---|---|---|
Host | 请求头 | 指定请求的目标主机名和端口号(HTTP/1.1 必须包含) | Host: www.example.com |
User-Agent | 请求头 | 标识客户端(浏览器、操作系统等)信息 | Mozilla/5.0 (Windows NT 10.0)... |
Accept | 请求头 | 指定客户端可接受的响应内容类型(MIME 类型) | text/html,application/xhtml+xml |
Accept-Language | 请求头 | 指定客户端可接受的语言 | en-US,en;q=0.9,zh-CN;q=0.8 |
Accept-Encoding | 请求头 | 指定客户端可接受的编码方式(如压缩格式) | gzip, deflate |
Authorization | 请求头 | 提供身份验证凭证(如 Bearer Token、Basic Auth) | Bearer <token> |
Referer | 请求头 | 标明当前请求的来源页面 URL | https://www.google.com/ |
If-Match | 请求头 | 条件请求头,用于验证资源 ETag 是否匹配 | "67ab43" |
If-None-Match | 请求头 | 条件请求头,验证资源 ETag 是否不匹配(用于缓存更新) | "67ab43" |
If-Modified-Since | 请求头 | 条件请求头,验证资源是否在指定时间后被修改 | Wed, 21 Oct 2023 07:28:00 GMT |
Cookie | 请求头 | 客户端随请求发送的 Cookie 数据 | sessionid=abc123 |
GET/ POST的区别:
GET最常见的一种请求方式,当客户端要从服务器中读取文档时,当点击网页上的链接或者通过在浏览器的地址栏输入网址来浏览网页的,使用的都是GET方式。GET方法要求服务器将URL定位的资源放在响应报文的数据部分,回送给客户端。使用GET方法时,请求参数和对应的值附加在URL后面,利用一个问号(“?”)代表URL的结尾与请求参数的开始,传递参数长度受限制。
GET方式的请求一般不包含”请求数据”部分,请求数据以地址的形式表现在请求行。显然,这种方式不适合传送私密数据。另外,由于不同的浏览器对地址的字符限制也有所不同,一般最多只能识别1024个字符,所以如果需要传送大量数据的时候,也不适合使用GET方式。
和get一样很常见,对于上面提到的不适合使用GET方式的情况,可以考虑使用POST方式,因为使用POST方法可以允许客户端给服务器提供信息较多。POST方法将请求参数封装在HTTP请求数据中,以名称/值的形式出现,可以传输大量数据,这样POST方式对传送的数据大小没有限制,而且也不会显示在URL中
(简单来讲,就是GET一般用于我们点击网页其他链接时使用,POST一般就是我们从网页上下载资源的时候使用)
(2) 响应报文: 由状态行+消息报头+空行+响应正文四个部分组成
- 状态行:由HTTP协议版本号,状态码,状态消息 三部分组成;
- 消息报头:用来说明客户端要使用的一些附加信息;
- 空行
- 响应正文:主要就是服务端向客户端发送的数据,比如一个页面、照片、视频等
以下是抓取的一段响应报文
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Wed, 20 Oct 2021 06:46:15 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 737265<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content=""><meta name="author" content=""><link rel="icon" href="../../favicon.ico"><title>吉首大学 </title>
HTTP状态码与请求方法
HTTP有5种类型的状态码,具体的:
-
1xx:指示信息—表示请求已接收,继续处理。
-
2xx:成功—表示请求正常处理完毕。
200 OK:客户端请求被正常处理。
206 Partial content:客户端进行了范围请求。 -
3xx:重定向—要完成请求必须进行更进一步的操作。
301 Moved Permanently:永久重定向,该资源已被永久移动到新位置,将来对该资源访问都要使用本响应返回的若干个URI之一。
302 Found:临时重定向,请求的资源临时从不同的URI中获得。 -
4xx:客户端错误—请求有语法错误,服务器无法处理请求。
400 Bad Request:请求报文存在语法错误。
403 Forbidden:请求被服务器拒绝。
404 Not Found:请求不存在,服务器上找不到请求的资源。 -
5xx:服务器端错误—服务器处理请求出错。
500 Internal Server Error:服务器在执行请求时出现错误。
执行逻辑:
- 首先主线程接收到一个客户端发来的事件,如读/写等,并将该事件按照事件类型标识,并加入任务队列
eventLoop()
......//处理客户连接上接收到的数据else if (events[i].events & EPOLLIN){dealwithread(sockfd);}else if (events[i].events & EPOLLOUT){dealwithwrite(sockfd);......}
void WebServer::dealwithread(int sockfd)
{util_timer *timer = users_timer[sockfd].timer;//reactor(反应堆),就是IO多路复用,收到事件后,根据事件类型分配给某个线程if (1 == m_actormodel){if (timer){adjust_timer(timer);}//若监测到读事件,将该事件放入请求队列m_pool->append(users + sockfd, 0); //users是一个数组指针,sockfd是索引,因此这个表示的就是当前处理的客户端的对象//stat:0表示read事件,1表示write事件while (true){if (1 == users[sockfd].improv){if (1 == users[sockfd].timer_flag){deal_timer(timer, sockfd);users[sockfd].timer_flag = 0;}users[sockfd].improv = 0;break;}}}
- 如果有事件添加进入任务队列,则会通知线程池,有空闲线程则会取出任务来进行执行,执行线程执行函数run()(内核是run函数,但是run不是静态函数,所以不能作为线程执行函数,而是在外层套了一个壳的worker()函数)
void threadpool<T>::run()
{while (true){m_queuestat.wait();//等待信号m_queuelocker.lock();if (m_workqueue.empty()){m_queuelocker.unlock();continue;}T *request = m_workqueue.front();m_workqueue.pop_front();m_queuelocker.unlock();if (!request)continue;if (1 == m_actor_model) //reactor{if (0 == request->m_state){if (request->read_once()){request->improv = 1;connectionRAII mysqlcon(&request->mysql, m_connPool);request->process();}else{request->improv = 1;request->timer_flag = 1;}}else{if (request->write()){request->improv = 1;}else{request->improv = 1;request->timer_flag = 1;}}}else{connectionRAII mysqlcon(&request->mysql, m_connPool);request->process();}}
}
- 然后根据事件的类型(request->state),来选择执行相应的函数,这里我们以读事件为例。首先,客户端发来请求响应,服务端需要先将客户端发来的请求响应的内容保存下来然后再进行解析,保存请求响应的函数即是read_once()函数
该函数的逻辑也比较简单,主要就是将套接字发送的内容储存到m_read_buf这个缓存区中
//循环读取客户数据,直到无数据可读或对方关闭连接
//非阻塞ET工作模式下,需要一次性将数据读完
bool http_conn::read_once()
{if (m_read_idx >= READ_BUFFER_SIZE){return false;}int bytes_read = 0;//LT读取数据if (0 == m_TRIGMode){bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);m_read_idx += bytes_read;if (bytes_read <= 0){return false;}return true;}//ET读数据else{while (true){bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);if (bytes_read == -1){if (errno == EAGAIN || errno == EWOULDBLOCK)break;return false;}else if (bytes_read == 0){return false;}m_read_idx += bytes_read;}return true;}
}
- 我们得到缓冲区中的请求响应后,就需要对其进行解析,解析函数为process()函数,这个函数会先使用process_read()函数对请求响应进行解析,然后使用process_write()输出回应报文
void http_conn::process()
{HTTP_CODE read_ret = process_read(); // 请求报文处理,限定结果在枚举范围之内if (read_ret == NO_REQUEST) //如果请求不完整,需要继续接收请求数据{modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode);return;}bool write_ret = process_write(read_ret);//相应报文处理if (!write_ret){close_conn();}modfd(m_epollfd, m_sockfd, EPOLLOUT, m_TRIGMode);
}
- process_read()函数是请求报文解析函数,是http的核心函数之一,我们可以结合下面的图来看
/* 该函数为请求报文处理函数,通过while循环来实现对主从状态机的封装,其中主状态机为process_read()函数,从状态机为parse_line()函数*/
http_conn::HTTP_CODE http_conn::process_read()
{LINE_STATUS line_status = LINE_OK;HTTP_CODE ret = NO_REQUEST;char *text = 0;//m_checked_state 主状态机状态为CHECK_STATE_REQUESTLINE时,该条件涉及解析消息体//line_status 从状态机状态转移为LINE_OK时,该条件涉及解析请求行while ((m_check_state == CHECK_STATE_CONTENT && line_status == LINE_OK) || ((line_status = parse_line()) == LINE_OK)){text = get_line(); //得到改行的具体内容m_start_line = m_checked_idx;LOG_INFO("%s", text);switch (m_check_state){case CHECK_STATE_REQUESTLINE: //请求行,初始化的时候定义了{ret = parse_request_line(text);if (ret == BAD_REQUEST)return BAD_REQUEST;break;}case CHECK_STATE_HEADER: //头信息{ret = parse_headers(text);if (ret == BAD_REQUEST)return BAD_REQUEST;else if (ret == GET_REQUEST){return do_request();}break;}case CHECK_STATE_CONTENT: //消息体{ret = parse_content(text);if (ret == GET_REQUEST)return do_request();line_status = LINE_OPEN;break;}default:return INTERNAL_ERROR;}}return NO_REQUEST;
}
6. 其中,主状态机则为process_read()函数,从状态机为parse_line()函数,parse_line主要作用就是从读缓冲区中读取一行内容,并将每一行结尾的\r\n改为\0\0。
//从状态机,用于分析出一行内容
//返回值为行的读取状态,有LINE_OK,LINE_BAD,LINE_OPEN
http_conn::LINE_STATUS http_conn::parse_line()
{char temp;for (; m_checked_idx < m_read_idx; ++m_checked_idx){temp = m_read_buf[m_checked_idx];if (temp == '\r'){if ((m_checked_idx + 1) == m_read_idx)return LINE_OPEN;else if (m_read_buf[m_checked_idx + 1] == '\n'){m_read_buf[m_checked_idx++] = '\0';m_read_buf[m_checked_idx++] = '\0';return LINE_OK;}return LINE_BAD;}else if (temp == '\n'){if (m_checked_idx > 1 && m_read_buf[m_checked_idx - 1] == '\r'){m_read_buf[m_checked_idx - 1] = '\0';m_read_buf[m_checked_idx++] = '\0';return LINE_OK;}return LINE_BAD;}}return LINE_OPEN;
}
- 如果返回LINE_OK,表示改行已经读完,并且该行的地址m_check_idx也已经更新,接着通过get_line()这个函数就返回改行具体的内容了。
然后进入switch循环,首先my_check_state的初始状态为CHECK_STATE_REQUESTLINE,即解析请求行,然后执行parse_request_line函数来解析,函数如下:
主要作用就是解析出m_method、m_url这两个属性,然后将my_check_state的状态修改为CHECK_STATE_HEADER。
//解析http请求行,获得请求方法,目标url及http版本号
http_conn::HTTP_CODE http_conn::parse_request_line(char *text)
{m_url = strpbrk(text, " \t");//用于查找第一个出现指定字符串的位置,如果找到,则返回指向该字符的指针,否则返回NULLif (!m_url){return BAD_REQUEST;}*m_url++ = '\0';char *method = text;if (strcasecmp(method, "GET") == 0)m_method = GET;else if (strcasecmp(method, "POST") == 0){m_method = POST;cgi = 1; //是否启用POST}elsereturn BAD_REQUEST;m_url += strspn(m_url, " \t"); //从m_url开始,跳过空白字符,返回第一个非空白字符的指针,该作用是确保指针指向有效的字符m_version = strpbrk(m_url, " \t");if (!m_version)return BAD_REQUEST;*m_version++ = '\0';m_version += strspn(m_version, " \t");if (strcasecmp(m_version, "HTTP/1.1") != 0) //检查版本是否为1.1return BAD_REQUEST;if (strncasecmp(m_url, "http://", 7) == 0) //如果前缀包括http://,则去掉{m_url += 7;m_url = strchr(m_url, '/');}if (strncasecmp(m_url, "https://", 8) == 0) //同上{m_url += 8;m_url = strchr(m_url, '/');}if (!m_url || m_url[0] != '/')return BAD_REQUEST;//当url为/时,显示判断界面if (strlen(m_url) == 1)strcat(m_url, "judge.html");m_check_state = CHECK_STATE_HEADER;return NO_REQUEST;
}
- 接下来是解析头部信息HEADER,函数如下:
其逻辑为如果检测到该行头部为“Connection:”、“keep-alive”等,则赋予相应的属性值,但是m_check_state不变;
如果检测到“Content-length”,这代表接下来是客户端发送过来的内容了,这样的话就转变m_check_state的属性为CHECK_STATE_CONTENT,代表下一次循环就要解析内容了
//解析http请求的一个头部信息
http_conn::HTTP_CODE http_conn::parse_headers(char *text)
{if (text[0] == '\0'){if (m_content_length != 0){m_check_state = CHECK_STATE_CONTENT;return NO_REQUEST;}return GET_REQUEST;}else if (strncasecmp(text, "Connection:", 11) == 0){text += 11;text += strspn(text, " \t");if (strcasecmp(text, "keep-alive") == 0){m_linger = true;}}else if (strncasecmp(text, "Content-length:", 15) == 0){text += 15;text += strspn(text, " \t");m_content_length = atol(text);}else if (strncasecmp(text, "Host:", 5) == 0){text += 5;text += strspn(text, " \t");m_host = text;}else{LOG_INFO("oop!unknow header: %s", text);}return NO_REQUEST;
}
- 以下是解析内容体的函数,因为在该项目中,客户端主要传输的对象很简单,就只有输入的用户名和密码。
如果,你希望在这个项目的基础上继续改进,一个主要的改进方向就是这个,你可以上传文件、图片等等。
那么既然客户端有上次内容,那么服务器肯定需要对内容做一个回应或者处理,那么就引出了接下来的do_request() 函数
//判断http请求是否被完整读入
http_conn::HTTP_CODE http_conn::parse_content(char *text)
{if (m_read_idx >= (m_content_length + m_checked_idx)){text[m_content_length] = '\0';//POST请求中最后为输入的用户名和密码m_string = text;return GET_REQUEST;}return NO_REQUEST;
}
-
do_request() 函数因为比较长,所以在这里就不直接粘贴出来了,大家对照着源码来学习吧。
-
有请求报文就会有响应报文,响应报文主要根据请求报文返回的状态来定义,在process()函数中,响应报文函数为process_write()函数
该函数的作用主要返回几种状态码,如404、200等,这些状态码的含义之前也已经叙述了,大家可以翻到上面去看。
bool http_conn::process_write(HTTP_CODE ret)
{switch (ret){case INTERNAL_ERROR:{add_status_line(500, error_500_title);add_headers(strlen(error_500_form));if (!add_content(error_500_form))return false;break;}case BAD_REQUEST:{add_status_line(404, error_404_title);add_headers(strlen(error_404_form));if (!add_content(error_404_form))return false;break;}case FORBIDDEN_REQUEST:{add_status_line(403, error_403_title);add_headers(strlen(error_403_form));if (!add_content(error_403_form))return false;break;}case FILE_REQUEST:{add_status_line(200, ok_200_title);if (m_file_stat.st_size != 0){add_headers(m_file_stat.st_size);m_iv[0].iov_base = m_write_buf;m_iv[0].iov_len = m_write_idx;m_iv[1].iov_base = m_file_address;m_iv[1].iov_len = m_file_stat.st_size;m_iv_count = 2;bytes_to_send = m_write_idx + m_file_stat.st_size;return true;}else{const char *ok_string = "<html><body></body></html>";add_headers(strlen(ok_string));if (!add_content(ok_string))return false;}}default:return false;}m_iv[0].iov_base = m_write_buf;m_iv[0].iov_len = m_write_idx;m_iv_count = 1;bytes_to_send = m_write_idx;return true;
}
这些基本上就是http的整个运行的逻辑框架了,大家有什么不懂的,评论区见~