【项目】Http服务器
项目简介
- 背景:
http协议被广泛使用,从移动端,pc端浏览器,http协议无疑是打开互联网应用窗口的重要协议,http在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。
- 描述:
本项目实现的是一个简易版http服务器,基本的业务逻辑是:通过网络套接字接收请求,而后分析请求,构建响应,返回响应给客户端。
本项目实现时会将主要核心模块抽离出来,着重于http分析请求和构建请求的过程。
项目中使用到的核心技术:网络套接字编程(socket流式套接字), 单例模式, 线程池,CGI机制, 多线程,HTTP协议, TCP/IP协议, c/s模式
开发环境为: centos 7 + vim/gcc/gdb + C/C++
网络协议栈相关知识
协议层状结构
协议分层
分层情况如下:
网络协议栈自底向上各层的作用:
-
数据链路层: 负责设备之间的数据帧的传送和识别. 例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作. 有以太网、令牌环网, 无线LAN等标准. 交换机(Switch)工作在数据链路层.
-
网络层: 负责地址管理和路由选择. 例如在IP协议中, 通过IP地址来标识一台主机, 并通过路由表的方式规划出两台主机之间的数据传输的线路(路由). 路由器
(Router)
工作在网路层. -
传输层: 负责两台主机之间的数据传输. 如传输控制协议
(TCP)
, 能够确保数据可靠的从源主机发送到目标主机. -
应用层: 负责应用程序间沟通,如简单电子邮件传输
(SMTP)
、文件传输协议(FTP)
、网络远程访问协议(Telnet)
等. 我们的网络编程主要就是针对应用层
数据封装和分用
也即是说,发送端给接收端发送HTTP数据时,是需要自顶向下经过网络协议栈完成数据报的封装,而后再经过网络的传播,最后客户端接收到我们发送的数据时,也是需要先自底向上经过网络协议栈完成解包和分用(将报头去掉,将有效载荷交付给上层),然后才拿到发送端发送过来的http数据;
而我们这个项目的编写的就是处于应用层的协议,而网络协议栈中的每一层协议在业务上都是相互解耦的,因为下层协议的屏蔽差异作用,所以每一层协议都认为自己是和对端的同层协议直接进行通信的;所以我们在编写应用层协议时,只需要关注应用层的事务细节即可,并不需要关心我们与远端的连接是怎么建立的,我们的数据是怎么进行中转,最后发送到客户端主机上的。
HTTP相关知识
http背景知识
简介
万维网WWW(World Wide Web)发源于欧洲日内瓦量子物理实验室CERN,正是WWW技术的出现使得因特网得以超乎想象的速度迅猛发展。这项基于TCP/IP的技术在短短的十年时间内迅速成为已经发展了几十年的Internet上的规模最大的信息系统,它的成功归结于它的简单、实用。在WWW的背后有一系列的协议和标准支持它完成如此宏大的工作,这就是Web协议族,其中就包括HTTP超文本传输协议。
在1990年,HTTP就成为WWW的支撑协议。当时由其创始人WWW之父蒂姆·伯纳斯·李(Tim Berners-Lee)提出,随后WWW联盟(WWW Consortium)成立,组织了IETF(Internet Engineering Task Force)小组进一步完善和发布HTTP。 [1]
HTTP是应用层协议,同其他应用层协议一样,是为了实现某一类具体应用的协议,并由某一运行在用户空间的应用程序来实现其功能。HTTP是一种协议规范,这种规范记录在文档上,为真正通过HTTP进行通信的HTTP的实现程序。
HTTP是基于B/S架构进行通信的,而HTTP的服务器端实现程序有httpd、nginx等,其客户端的实现程序主要是Web浏览器,例如Firefox、[Internet Explorer](https://baike.baidu.com/item/Internet Explorer/1537769?fromModule=lemma_inlink)、[Google Chrome](https://baike.baidu.com/item/Google Chrome/5638378?fromModule=lemma_inlink)、Safari、Opera等,此外,客户端的命令行工具还有elink、curl等。Web服务是基于TCP的,因此为了能够随时响应客户端的请求,Web服务器需要监听在80/TCP端口。这样客户端浏览器和Web服务器之间就可以通过HTTP进行通信了。 – 取自百度百科
发展历史
- 0.9
0.9协议是适用于各种数据信息的简洁快速协议,但是远不能满足日益发展的各种应用的需要。0.9协议就是一个交换信息的无序协议,仅仅限于文字。由于无法进行内容的协商,在双发的握手和协议中,并有规定双发的内容是什么,也就是图片是无法显示和处理的。 [3]
- 1.0
到了1.0协议阶段,也就是在1982年,Tim Berners-Lee提出了HTTP/1.0。在此后的不断丰富和发展中,HTTP/1.0成为最重要的面向事务的应用层协议。该协议对每一次请求/响应建立并拆除一次连接。其特点是简单、易于管理,所以它符合了大家的需要,得到了广泛的应用。
- 1.1
在1.0协议中,双方规定了连接方式和连接类型,这已经极大扩展了HTTP的领域,但对于互联网最重要的速度和效率,并没有太多的考虑。毕竟,作为协议的制定者,当时也没有想到HTTP会有那么快的普及速度。 [3]
关于HTTP1.1协议的具体内容可以参考RFC 2616。 [4]
-
2.0
HTTP2.0的前身是HTTP1.0和HTTP1.1。虽然之前仅仅只有两个版本,但这两个版本所包含的协议规范之庞大,足以让任何一个有经验的工程师为之头疼。网络协议新版本并不会马上取代旧版本。实际上,1.0和1.1在之后很长的一段时间内一直并存,这是由于网络基础设施更新缓慢所决定的 – 取自百度百科
http协议特点
- 客户/服务器模式(B(
Browser
web浏览器)/S,C/S) - 简单快速,HTTP服务器的程序规模小,因而通信速度很快。
- 灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
- 无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)
- 无状态
http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。 – 这就是http无状态的体现;
说明:
- 但是在当今无状态是不适用的;举个例子:比如你是bilili的大会员,如果http保持无状态的方式,就代表每次你想看一些大会员专享的电影时都需要重新输入自己的账号和密码,这对于用户体验来说是极其不友好的;所以如今的HTTP/1.1虽然也是无状态的,但是引进了cookie和sessionid 技术实现会话保持功能。
- http/1.0 无连接的特点在当今大多数场景下也是不适用的,因为如果每次获取完一个资源就将连接关闭,即每次获取资源的成本都是巨大的,所以http/1.1也实现了长连接,即只要一端没有想断开连接的需要时,双方就会一直保持连接,后续的通信工作都使用当前连接来完成(因为当前项目实现的是http/1.0, 所以是不涉及长连接部分的)
注:http本身是无状态的,保持状态的功能并不是通过http本身实现的
URL URI URN
- URI,是
uniform resource identififier
,统一资源标识符,用来唯一的标识一个资源 - URL,是
uniform resource locator
,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。 - URN,
uniform resource name
,统一资源命名,是通过名字来标识资源,比如mailto:java-net@java.sun.com。
URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。URL和URN都是一种URI.
URL是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI ;
URL格式
说明:
http://
:表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。user:pass
:表示的是登录认证信息,包括登录用户的用户名和密码。www.example.jp
: 表示的是服务器地址,通常以域名的形式表示。80
:表示的是服务器的端口号。(可省略)/dir/index.html
:表示的是要访问的资源所在的路径(/表示的是web根目录)。uid=1
:表示的是请求时通过URL传递的参数,这些参数以键值对的形式通过&符号分隔开。ch1
:表示的是片段标识符,是对资源的部分补充。 (可省略)
注:
/
并不是我们服务器中的根目录,而是web根目录,web根目录就是我们服务器中的一个普通目录,专门存放于网络服务相关的代码和数据,用户可以自行指定- 此外,如果我们在输入url时,如果没有请求指定的资源,默认是访问当前目录的目录首页(
index.html
);
例子:
我们平时访问的网站名实际上就是一个url: www.baidu.com;但我们实际中使用的url为什么却比上面简单如此之多呢?–当然是有人为我们做了更多的工作
如图:实际上在输入框输入www.baidu.com时,浏览器会自动为我们添加上协议名和端口号;而后服务端会自动将我们的uri转化成对应的web根目录下的路径,而上面我们最后转化出来的路径就是/index.html --即网站首页
http请求报文和响应报文格式
Http请求格式
说明:
- 请求行: 包含了HTTP的请求方法和uri(所请求资源的标识符),当前HTTP版本(方便双方进行交流)
- 报头: 报文的描诉字段,格式为
key: value
字段 - 空行:报头有正文数据的分割符
- 正文数据: 各种类型的数据都有,传输的数据类型取决于报头中的(
Content-Type
存放的信息)
http响应报文格式
说明:
- 状态行: 包括了当前通信的HTTP版本,以及请求资源结果的状态码和及其描述
- 报头: 报文的描诉字段,格式为key:value字段
- 空行:报头有正文数据的分割符
- 正文数据: 各种类型的数据都有,传输的数据类型取决于报头中的(
Content-Type
存放的信息)
http请求方法
方法 | 说明 | 支持的http协议版本 |
---|---|---|
GET | 获取资源 | 1.0,1.1 |
POST | 传输实体文件 | 1.0,1.1 |
PUT | 传输文件 | 1.0,1.1 |
HEAD | 获取报文首部 | 1.0,1.1 |
DELETE | 删除文件 | 1.0,1.1 |
OPTIONS | 询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 要求使用隧道协议连接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.0 |
UNLINE | 断开连接关系 | 1.0 |
GET
: 获取资源,获取被URI标识的资源 ;也可以传输资源,和post区别就是使用uri进行资源的传输POST
: 传输实体主体- PUT: 传输文件,将指定文件放的URI所标示的路径,类似ftp,但是有安全问题,大部分web都不用
- HEAD: 获取报文首部,和GET类似,但是不返回报文主体部分。用于确认URI的有效性以及资源的日期等
- DELETE: 与PUT相反,删除URI指定的资源,不安全,一般也不会被使用
- OPTIONS: 询问支持方法
- TRACE: 追踪路径,自行了解
- CONNECT: 使用隧道协议链接代理
其中http请求方法最常见的就是GET, POST方法;get常用于获取资源,post常用于上传资源,但get也可以通过uri上传资源;
GET vs Post
- get 也可以使用uri上传资源,但这种方法上传资源的方式是比较透明,会直接回显到url上,不够安全,且传输资源的长度也具有限制
- 而POST传输数据是通过正文数据段进行传输的较为安全一点;但在互联网中是不存在绝对安全的
http状态码描诉
状态码分类:
类别 | 原因短语 | |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error (客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error (服务器错误状态码) | 服务器处理请求出错 |
常见状态码
200 OK
: 客户端发来的http请求,被正确处理了204 No Content
: 表明请求结果被正确处理了,但是响应信息中没有响应正文206 Partial Content
:该状态码表示客户端对服务器进行了范围请求,而且服务器成功的执行了这部分GET请求,响应报文中包含由Content-Range指定的实体内容范围。301 Moved Permanently 永久性重定向
:该状态码表示请求的资源已经被分配了新的URI,以后应使用新的URI,也就是说,如果之前将老的URI保存为书签了,后面应该按照响应的Location首部字段重新保存书签302 Found
: 临时性重定向307 Temporary Redirec
: 临时重定向400 Bad Request
: 该状态码表明请求报文中存在语法错误,需修改请求内容重新发送,另外,浏览器会像200 OK一样对待该状态码。403 Forbidden
:该状态码表明浏览器所请求的资源被服务器拒绝了。服务器没有必要给出详细理由,如果想要说明,可以在响应实体内部进行说明。404 Not Found
: 所请求的资源不存在500 Internal Server Error
: 表明服务器端在执行的时候发生了错误,可能是Web本身存在的bug或者临时故障503 Server Unavailable
: 该状态码表明服务器目前处于超负载或正在进行停机维护状态,目前无法请求处理。这种情况下,最好写入Retry-After首部字段在返回给客户端
CGI机制
概念
CGI(Common Gateway Interface)
是WWW技术中最重要的技术之一,CGI是"公共网关接口"(Common Gateway Interface)的缩写,是一种标准的协议/接口,定义了Web服务器如何调用应用程序,并将程序的输出返回给Web服务器,客户端能看到Web网页的内容。CGI程序通常用于处理用户提交给Web服务器的请求,生成动态内容,并将其返回给Web服务器和浏览器。- CGI程序是运行在Web服务器上的可执行文件,当Web服务器收到用户请求时,CGI程序接收请求,执行相应的操作,并将结果返回给客户端,常见的操作包括表单处理、生成动态内容、与数据库等其他服务操作。
- 在CGI机制中,Web服务器会通过环境变量的方式将请求信息提供给CGI程序,并通过标准输入和输出的方式实现数据的传递。CGI程序处理完数据后,将结果通过标准输出返回给Web服务器,Web服务器再把结果返回给客户端的浏览器。
- CGI程序可以使用通用编程语言(如Perl、PHP、Python、Java等)编写。然而,CGI程序的架构存在一定的性能问题,因为每个请求都需要启动一个新的进程,占用大量系统资源。因此,现代Web应用程序一般采用其他更高效的架构方式,如FastCGI、WSGI等。
示意图:
项目中的应用及其实现思路
项目中的应用
- GET方法的URI携带参数时
- POST方法
- 请求的资源是可执行程序时
以上3种情况都需要web服务器执行对应的操作(调用cgi程序–通常是一个个子进程),而后从cgi程序得到运行结果
- 实现思路
一:建立通信匿名管道
父进程构建匿名俩个管道(input,output) ,约定父进程使用output管道对子进程进行写入数据,子进程从output管道中读取父进程写入的数据;父进程从input管道读取子进程执行CGI程序的结果;即父进程只需将input[1] , output[0]关闭即可,而子进程只需将input[0], output[1]关闭即可;
使用匿名管道是因为web服务端的进程和执行CGI的进程具有血缘关系,使用匿名管道进行数据交互简单高效;使用俩个匿名管道是因为匿名管道是半双工的,只允许数据从一端流向另一端,而父子进程之间是有数据交互的,所以需要俩个匿名管道;
示意图:
二: 父子之间数据交互逻辑
我们在这里也做出约定:
- GET方法使用环境变量向子进程中导入数据
- POST方法使用写入文件的方式将正文数据导入
get方法传递的数据是在uri里的,通常都是比较短小,如果使用文件写入的方式通过匿名管道,效率反而不高;所以我们可以选用环境变的方式将数据导入到子进程中去
post方法使用管道传输方式,父子间使用read,write
实现数据交互,且导入数据后,子进程需要知道该数据的大小,才知道应该从套接字读取多少字节进行数据处理;所以使用管道传输数据给子进程时,还需要通过环境变量将数据大小导入给子进程
三: 重定向标准输出,标准输入
因为子进程实现进程替换之后,子进程的代码和数据都会被替换,也即我们匿名管道的数据input[2],output[2]
数据会丢失,也就是说进程替换之后,子进程会不认识匿名管道的fd,但底层的数据结构是不会变的(fd_array, file struct , 页表…),等等;即我们所打开匿名管道还是存在的,只是子进程不知道该匿名管道对应的文件描述符是多少;所以我们可以做出一个规定,子进程向标准输入流中读取数据就是从output管道中读取父进程写入的数据,通过标准输出流输出数据就是从input管道向父进程写入程序运行结果(使用重定向实现即可)
伪代码:
dup2(input[1], 1) dup2(output[0], 0)
四:对子进程进行程序替换逻辑
使用exec*
函数对子进程进行程序替换;
不使用线程执行CGI程序的原因:因为linux下的线程是轻量级进程,如果对线程进行程序替换,实际上就是对整个进程(线程组)进行程序替换(因为线程组之间是公用一个页表的),而进程替换就是修改页表的指向,所以使用线程执行cgi程序是不合适的
意义
http处理请求报文的逻辑
项目中将http请求报文解析完毕之后的处理逻辑:
-
请求方法为GET
- URI不带参: 非CGI处理,寻找对应请求资源返回即可
- URI带参: CGI处理,从CGI中提取出参数,将参数传递给子进程,执行对应CGI程序之后,获取执行结果
-
请求方法为POST
CGI处理,从正文中获取参数,将参数传递给子进程进行处理,获取执行结果
-
请求的资源是可执行程序
CGI处理
CGI机制的意义
- 通过CGI程序,Web服务器可以将用户提交的数据以特定的格式传送给外部程序进行处理,外部程序也可以将处理结果以同样的格式返回给Web服务器,由Web服务器再发送回客户端的浏览器
- 将服务逻辑和处理逻辑进行了解耦,更利于web服务的维护和管理,web服务端只需将用户需求交付,而后获取执行结果即可。
项目整体框架
将上面的各种服务抽象成一个个类即可:
- 提供套接字服务的类(
TcpServer
) , Http服务器端(Httpserver
), 业务处理类(EndPoint
), 线程池(ThreadPool
) - 其他:日志类(
Log
), 任务类(Task
), 以及与任务类搭配的回调方法(callback
),还有工具方法类(Util
)
日志信息类实现
日志等级
- INFO: 正常执行
- WARNING: 告警信息
- ERROR: 部分逻辑运行出错,但不会影响整个服务的正常运行
- FATAL: 致使整个服务直接崩掉的错误
日志信息格式:
#pragma once
#include <iostream>
#define INFO 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define LOG( level, message) Log (#level, message, __FILE__, __LINE__)
//日志信息 :[日志等级] [时间戳] [日志内容] [错误文件名称] [文件行数]
void Log(std::string level, std::string message,std::string file_name, int line)
{
std::cout << '[' << level << ']' << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}
说明:
- 当前操作时间,直接使用时间戳表示即可(time(nullptr))
__FILE__,__LIEN__
是c语言中的宏定义,作用分别是获取当前文件的文件名,和文件行数;而宏是在文件编译时进行替换的,而我们使用这个俩个宏定义,刚好可以在调用日志方法的文件里编译程序时,获取当前文件的文件名和文件行数
套接字服务类
套接字服务类
- 编写创建套接字,绑定套接字,获取连接…等业务逻辑
- 设计此类为单例对象,使用懒汉模式实现单例对象,外部调用getinstance() 获取当前单例对象
- 单例对象:需将构造私有化,且将拷贝构造, 赋值运算符重载俩个函数禁掉
#define BACKLOG 5 // listen 函数第二个参数
class TcpServer
{
private:
int _port;
int listen_sock;
static TcpServer * svr;
private:
TcpServer(int port) : _port(port), listen_sock(-1)
{}
TcpServer(const TcpServer& s) = delete;
TcpServer& operator= (const TcpServer& s) = delete;
public:
static TcpServer * getinstance(int port) // 单例模式 单例对象获取函数
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态锁初始化 不用自己释放
if(nullptr == svr) // 双判断 提高单例对象获取效率
{
pthread_mutex_lock(&lock);
if (nullptr == svr){
svr = new TcpServer(port);
svr -> InitServer();
}
pthread_mutex_unlock(&lock);
}
return svr;
}
void InitServer() // 套接字初始化
{
Socket();
Bind();
Listen();
LOG(INFO, "tcp_server init ... success ");
}
int Sock()
{
return listen_sock;
}
// 创建套接字
void Socket()
{
listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
LOG(FATAL, "socket error!");
exit(1);
}
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // socket 端口重用 我们的服务异常终止时 是先进入着time_wait状态的 不能立马重启 就会有很大问题
LOG(INFO, "create socket ... success ");
}
// 绑定服务端口
void Bind()
{
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 主机序列转网络序列
local.sin_addr.s_addr = INADDR_ANY; // 云服务器上不能直接绑定公网IP
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
LOG(FATAL,"bind error!");
exit(2);
}
LOG(INFO, "bind socket ... success ");
}
// 将服务置于就绪状态 等待连接
void Listen()
{
if (listen(listen_sock, BACKLOG) < 0)
{
LOG(FATAL, "listen error!");
exit(3);
}
LOG (INFO, "listen socket ... success ");
}
~TcpServer() {
if (listen_sock >= 0)
{
close(listen_sock);
}
}
};
TcpServer* TcpServer::svr = nullptr;
说明:
- 服务端不能绑定公网ip,通常使用
INADDR_ANY
(netinet.h里面定义的宏值),作用是使用所有当前服务器能用的ip接收请求,通常服务端都是服务器,而服务器不止配备了一张网卡,如果只绑定一个ip的话,就只不能将多网卡的优势发挥 PTHREAD_MUTEX_INITIALIZER
可以用于初始化全局或静态锁,且该互斥锁不需要我们自己销毁- 使用
setsockopt
接口解决,服务器服务因为某种原因异常终止了不能立即重启服务的问题; 因为服务异常终止时,主打断开连接的一方最后会处于TIME_WAIT
状态,即服务器服务异常终止后,连接会被保持一段时间,而导致服务器端口被一直占用;使用该接口可以使得对应端口进行复用 - 双判断搞高单例对象获取效率:只有第一次获取该对象的时候, 需要加锁进行初始化,后续都只需直接获取该对象即可,所以我们可以多做一层判断,如果该单例对象已经不为空了, 我们就不需要再进行加锁判断了
请求报文结构设计
说明:
- 保存报文大致结构的四个字段:
- request_line (请求行)
- request_header(http报头) 使用vector按行存储(因为报头字段格式都是
key: value
) - blank(空行)
- request_body(正文字段)
- 解析需求:
- 解析request_line
- method (请求方法)
- uri
- path (请求资源路径)
- query_string (相关业务逻辑参数)
- version (http版本)
- 解析request_header
- 将每个字段都建立映射(使用unordered_map) 通过字段名称就可以获取对应内容
- post请求方法需要读取正文字段时
- Content_Length
- 解析request_line
- 构建报文时需要:
- 处理响应正文时需要:request报文所申请资源(静态网页)大小 :size
- 判断是否需要使用CGI机制: cgi
- 构建响应报头时需要:
- 请求资源文件后缀 suffix
补充:
我们访问某个资源时,实际上uri部分是划分为俩部分的(path?query_string) query_string 即一些与任务处理相关的参数
class HttpRequest
{
public:
std::string request_line; // 请求行
std::vector<std::string> request_header; // http报头
std::string blank; // 空行
std::string request_body; // http正文字段
// 保存请求行信息
std::string method;
std::string uri; // path?query_string
std::string version;
// uri
std::string path;
std::string query_string;
// 保存报头的key: value
std::unordered_map<std::string, std::string> header_kv;
int content_length;
bool Cgi;
int size; // 请求的资源的大小
std::string suffix; // 所求资源后缀
public:
HttpRequest() :content_length(0), Cgi(false)
{}
~HttpRequest(){}
};
响应报文结构设计
说明:
- 构建响应报文四大字段:
- status_line (状态行)
- response_header (响应报头)
- blank (空行)
- response_body (响应正文)
- 构建需求:
- status_code 状态码
- fd 文件资源的fd(非CGI机制使用)
class HttpResponse
{
public:
std::string status_line;
std::vector<std::string> response_header;
std::string blank;
std::string response_body;
//状态码
int status_code;
int fd;
public:
HttpResponse() :blank(END_LINE), status_code(OK)
{}
~HttpResponse(){}
};
http服务端主体逻辑
- 不断获取连接(循环),将连接及其往后的业务处理打包成任务丢进任务队列里
class HttpServer
{
private:
int port; // 服务端口 默认为8081
bool stop; // 判断服务是否停止
public:
HttpServer(int _port = PORT) : port(_port), stop(false)
{}
void InitServer()
{
// 该进程忽略SIGPIPE信号,不然会在线程进行写入时,对端忽然间关闭连接, 会导致server崩溃
signal(SIGPIPE, SIG_IGN);
}
void Loop()
{
LOG(INFO, "Http_server Loop Begin ");
TcpServer* tsvr = TcpServer::getinstance(port);
while(!stop)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len); // 获取连接
if (sock < 0){
continue; // 获取连接失败 继续获取
}
LOG(INFO, "Get a new link ");
Task task(sock);
// 将任务推送到任务队列里
ThreadPool::GetInstance()->PushTask(task);
}
}
~HttpServer()
{}
};
任务类
只是简单抽离出来的一个概念,算是线程池和服务端之间的一个联系的桥梁,将任务塞到任务队列里,线程拿到任务,就可以调用该任务的回调函数,拿到任务的线程就可以开始工作了
class Task
{
private:
int sock;
Callback handler;
public:
Task() {}
Task(int _sock) : sock(_sock)
{
}
// 处理任务
void ProcessOn()
{
handler(sock);
}
~Task() {}
};
线程池及任务队列
懒汉模式下实现的线程池
- 先创建好一批线程,这批线程创建好之后,都会在任务队列下进行等待(准确来说应该是描述该任务队列资源状况的条件变量下),只要被插入到队列里,就会唤醒其中一个线程(通过改变条件变量状态的方式通知),而每个线程又是互斥的,所以在线程访问任务队列时,需要有互斥锁的保护
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>
#include "Task.hpp"
#include "Log.hpp"
#define NUM 6
class ThreadPool{
private:
std::queue<Task> task_queue;
bool stop;
int num;
pthread_mutex_t lock;
pthread_cond_t cond;
static ThreadPool* single_instance;
private:
ThreadPool(int _num = NUM): num (_num), stop(false)
{
pthread_mutex_init(&lock, nullptr);
pthread_cond_init(&cond, nullptr);
}
ThreadPool (const ThreadPool& tp) = delete;
ThreadPool& operator=(const ThreadPool & tp) = delete;
public:
static ThreadPool* GetInstance()
{
static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
if (single_instance == nullptr)
{
pthread_mutex_lock(&_mutex);
if(single_instance == nullptr)
{
single_instance = new ThreadPool();
single_instance->InitThreadPool();
}
pthread_mutex_unlock(&_mutex);
}
return single_instance;
}
bool TaskQueueIsEmpty()
{
return task_queue.empty();
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void ThreadWakeup()
{
pthread_cond_signal(&cond);
}
void ThreadWait()
{
pthread_cond_wait(&cond, &lock);
}
static void * ThreadRoutine(void *args)
{
ThreadPool * tp = (ThreadPool*) args;
while(true){
Task t;
tp->Lock();
while (tp->TaskQueueIsEmpty()){
tp->ThreadWait();
}
tp->PopTask(t);
tp->Unlock();
t.ProcessOn();
}
}
bool InitThreadPool()
{
for (int i = 0; i < num; i++)
{
pthread_t pid;
if ( pthread_create(&pid, nullptr, ThreadRoutine, this) != 0){
LOG (FATAL, "ThreadPool Init error");
return false;
}
LOG(INFO, "ThreadPool Init success");
return true;
}
}
void PushTask(Task& task)
{
Lock();
task_queue.push(task);
Unlock();
ThreadWakeup();
}
void PopTask(Task& task)
{
task = task_queue.front();
task_queue.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
};
ThreadPool* ThreadPool::single_instance = nullptr;
注:
- 类内静态成员变量需要在类外进行初始化
EndPoint类编写(业务类)
注:本项目只实现了get,post请求方法的相关逻辑
EndPoint即终端,我们将业务类处理为终端类,核心方法:
- RecvHttpRequest
- BuildHttpResponse
- SendHttpResponse
EndPoint主体逻辑:
//业务端EndPoint
class EndPoint{
private:
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
int _sock; //通信的套接字
bool stop; // 判断业务是是否需要终止
public:
EndPoint(int sock)
:_sock(sock)
{}
//读取请求
void RecvHttpRequest();
//处理请求
void HandlerHttpRequest();
//构建响应
void BuildHttpResponse();
//发送响应
void SendHttpResponse();
~EndPoint()
{}
};
接收和解析请求报文(RecvHttpRequest)
再将其细分
- 读取请求行
- 读取请求报头
- 解析请求行
- 解析请求报头
- 读取请求正文(post)
读取请求行 RecvHttpRequestLine
因为HTTP请求报文都是以一行为分割符的,所以我们可以直接按行读,将请求行和请求报文进行读取,但是每行的结束可能会有以下3种情况: \r\n \r \n
我们需要将其都统一处理成同一种格式
-
我们读取每行采取按字符读取的方式,遇到\n,或是普通字符正常读取即可,\n就是该行结尾了
-
当读取到\r 时
- \r后面是下一行数据,将\r转化成\n
- \r 后面是\n, 将\n也从套接字中读取出来,而后继续读取下一行
依照上面代码思路实现的ReadLine方法
static int ReadLine(int sock, std::string& out)
{
char ch = 'X';
while (ch != '\n')
{
ssize_t s = recv(sock, &ch, 1, 0);
if(s > 0)
{
// 处理\r 和 \r\n的情况 将这俩种情况都转化成 \n
if(ch == '\r')
{
// 因为不能判断\r 后面是否具有\n 所以需要先偷窥一下\r 后面是否具有\n
recv(sock, &ch, 1, MSG_PEEK);
if (ch == '\n')
{
// 窥探成功 \r 后面具有 \n
recv(sock, &ch, 1, 0); // 取出\n
}else{
ch = '\n' ; //将\r 处理成 \n
}
}
// \n 或者是正常字符
out.push_back(ch);
}
else if (s == 0)
{
// 客户端断开连接
return 0;
}
else{
return -1; // 出现错误
}
}
}
说明:
- 当我们按字符读,读取到\r时,我们不能贸然读取下一个字符,不然可能会导致下一行数据不完整,于是我们就可以使用recv函数进行窥探下一个字符是什么(选项填MSG_PEEK)表示窥探下一个字符,作用是查看该字符,但不从中取走该字符
RecvHttpRequestLine方法具体实现
- 因为
\n
在解析字符串并没有什么作用,所以可以在存储时将其去掉,前面不去掉的原因是为了是按行读取的逻辑更清晰
bool RecvHttpRequestLine() // 读取请求行
{
auto& line = http_request.request_line;
if( Util::ReadLine(sock, line) <= 0){
stop =true; // 读取请求行出现错误,停止后续的操作, 直接将该套接字关掉即可
}
line.resize(line.size() - 1); // 去掉\n
LOG(INFO, line);
return stop;
}
读请求报头RecvHttpRequestHeader
同上面思路一致,也是按行读取即可,但为了后续分解报头中的属性字段更方便,使用vector将每行数据存储起来;而读取请求报头的停止标志也简单,就是当我们读取到连续的\n时,就说明我们已经读到了空行,就可以停止报头的读取了
bool RecvHttpRequestHeader() // 读取报头
{
std::string line;
while(true){
line.clear();
if (Util::ReadLine(sock,line) <= 0){
stop = true;
break;
}
if (line == "\n"){
http_request.blank = line;
break;
}
line.resize(line.size() - 1);
http_request.request_header.push_back(line);
LOG(INFO,line);
}
return stop;
}
解析请求行ParseHttpRequestLine
- 将3个字段提取出来
- 将请求方法统一装成大写,因为http并没有统一标准的,所以可能有些http报文的请求方法并不是全大写的
请求行大致格式
GET / HTTP/1.1
- 请求行分为3个字段,请求方法,URI,HTTP版本,3个字段以空格作为分割符,所以我们切分时通过空格进行切分
使用stringstream进行切分,stringstream默认的分割符就是空格,然后使用transform函数将method统一转成大写
void ParseHttpRequestLine()
{
auto & line = http_request.request_line;
std::stringstream ss(line);
ss >> http_request.method >> http_request.uri >> http_request.version;
//std::cout << "debug: " << http_request.method << std::endl;
auto &method = http_request.method;
std::transform(method.begin(), method.end(), method.begin(), ::toupper);
}
解析请求报头ParseHttpRequestHeader
报头中的属性字段都是以这样形式存在的,所以我们可以直接使用": "作为分割符,将报头中的每行解析出来
key: value
具体实现:
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
for( auto &iter : http_request.request_header)
{
if(Util::CutString(iter, key, value, SEP)){
http_request.header_kv.insert({key,value});
}
}
}
// 字符串切割方法
static bool CutString(const std::string &target , std::string &sub1_out, std::string &sub2_out, std::string sep) // sep用&会报错
{
size_t pos = target.find(sep);
if (pos != std::string::npos){
sub1_out = target.substr(0, pos);
sub2_out = target.substr(pos + sep.size());
return true;
}
return false;
}
说明:
- 因为字符串切割后面还会用得到,所以将其也放到工具类,设计成一个方法
解析请求正文ParseHttpRequestBody
- 因为只有POST才需要从Http报文中提取正文,所以我们可以设计出一个子函数,判断该报文是否需要读取正文,如果需要读取就顺便将报头中的(Content-Length)字段解析出来,方便后续正文数据进行提取
bool IsNeedParseHttpRequestBody() //子函数
{
auto &method = http_request.method;
if(method == "POST"){
auto &header_kv = http_request.header_kv;
auto iter = header_kv.find("Content-Length");
if(iter != header_kv.end()){
LOG(INFO, "POST Method, Content-Length: "+iter->second);
http_request.content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
void ParseHttpRequestBody()
{
if(IsNeedParseHttpRequestBody()){
int content_length = http_request.content_length;
auto &body = http_request.request_body;
char ch = 0;
while(content_length){
ssize_t s = recv(sock, &ch, 1, 0);
if(s > 0){
body.push_back(ch);
content_length --;
}
else{
break;
}
}
LOG(INFO, body);
}
}
构建响应报文 BuildHttpresponse
构建响应报文其实可以分为俩大部分,一部分是处理上述解析出来的用户请求,另一部分是构建响应报文,所以实际上EndPoint还可以在细分为4部分,这样逻辑可能更清楚一点,但看个人想法,因为觉得是构建响应报文这一类我就将其归进来了
构建响应报文部分组成:
- 构建响应状态行
- 构建响应报文(报头,空行,正文)
处理用户请求
-
判断请求方法是否合法,因为本项目只构建了GET和POST方法,所以其他的请求方法都是不合法的
-
将web根目录转化为本地路径,判断用户请求资源是否存在
2.1 请求资源不存在,直接返回错误响应(404)
-
请求资源存在情况下,继续判断请求资源的类型
3.1 请求的只是网页内容或是目录文件,直接返回对应的静态网页即可(index.html)即可
3.2 请求的是可执行程序,留给CGI机制处理
-
CGI机制处理Post请求方法,及其GET请求方法带参情况
void BuildHttpResponse()
{
std::string _path;
struct stat st;
std::size_t found = 0;
auto &code = http_response.status_code;
if(http_request.method != "GET" && http_request.method != "POST" ){
//非法请求
LOG(WARNING, "mothod is not right ");
code = BAD_REQUEST;
goto END;
}
if(http_request.method == "GET"){
ssize_t pos = http_request.uri.find("?");
if (pos != std::string::npos)
{
Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");
http_request.Cgi = true;
}
else
{
http_request.path = http_request.uri;
}
}
else if (http_request.method == "POST"){
// POST
http_request.path = http_request.uri;
http_request.Cgi = true;
}
else{
// do nothing
}
// 转化uri为网络根目录
_path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += _path;
if (http_request.path[http_request.path.size() - 1] == '/' ) {
http_request.path += HOME_PAGE; // path后面没有带指定路径 就只是获取当前网站首页
}
//判断请求资源是否存在
if (stat(http_request.path.c_str(), &st) == 0){
//资源存在
//判断资源是什么 1.目录 2.可执行文件 3.资源
if (S_ISDIR(st.st_mode)){
//1.目录文件 将该目录的默认目录首页html 显示当前目录的首页
http_request.path += '/';
http_request.path += HOME_PAGE;
stat(http_request.path.c_str(), &st) ; // 更新一下st
}
if (st.st_mode & S_IXUSR || st.st_mode & S_IXGRP || st.st_mode & S_IXOTH){
//请求的资源是可执行程序 特殊处理
http_request.Cgi = true;
}
http_request.size = st.st_size; //获取对应文件的大小 即响应报文的正文大小
}
else{
//资源不存在
std::string Info = http_request.path;
Info += "NOT_FOUND";
LOG(WARNING, Info);
code = NOT_FOUND;
goto END;
}
found = http_request.path.rfind("."); // 查询获取资源的后缀
if (found == std::string::npos)
{
http_request.suffix = ".html";
}
else{
http_request.suffix = http_request.path.substr(found);
}
std::cout << "path: " << http_request.path << std::endl;
if (http_request.Cgi){
//ProcessCgi
code = ProcessCgi();
}
else {
//ProcessNonCgi -- 返回静态网页 -- 简单的网页返回
code = ProcessNonCgi();
}
END:
BuildHttpResponseHelper();
}
说明:
-
stat接口,可以用来判断某个文件是否存在,而后我们可以从中得到一个struct stat结构体,里面保存了许多关于该文件的描述;
其中后面判断一个文件是否为目录文件就是用S_ISDIR()接口和st_mode判断以及st_size获取该资源长度
构建响应状态行BuildHttpResponseHelper
状态行包括如下格式:
HTTP/1.1 200 OK
- 状态行通常是由3个字段组成:HTTP版本号, 请求状态码, 请求状态码描诉
所以我们只需将这3个字段拼接起来即可
注:
- Code2Desc是将状态码转成字符描诉的方法,这里只展示大体思路,如果想了解细节,可以后续去浏览代码源码
void BuildHttpResponseHelper()
{
//构建状态行
auto &code = http_response.status_code;
auto &status_line = http_response.status_line;
status_line = HTTP_VERSION;
status_line += " ";
status_line += std::to_string(code);
status_line += " ";
status_line += Code2Desc(code);
status_line += END_LINE;
//出现差错时 path并未被构建 或是有错误
std::string path = WEB_ROOT;
path += "/";
switch(code)
{
case OK:
BuildOkResponse();
break;
case BAD_REQUEST:
path += PAGE_400;
HandlerError(path);
break;
case NOT_FOUND:
path += PAGE_404;
HandlerError(path);
break;
case SERVER_ERROR:
path += PAGE_500;
HandlerError(path);
break;
default:
break;
}
}
构建响应报文HandlerError , BuildOKResponse
-
HandlerError
主要用于构建错误情况下的报文处理;
这里只提及几点处理细节:
- 因为是错误报文,所以Content-Length字段都是“text/html”类型
- 错误报文的正文数据是错误码对应的html资源,所以我们构建时需要使用stat函数获取该html网页文件的大小,方便后续发送报文时进行数据传输
-
BuildResponse
- 报头部分
- Content-Type字段使用Suffix2Desc()将文件后缀转成对应的文件类型
- Content-Length字段
- 处理机制非CGI: 正文的长度即为http所请求的资源大小
- 处理机制为CGI:正文长度为CGI程序处理之后的结果大小(内容放在repsonsebody)
- 报头部分
void HandlerError(std::string page)
{
http_request.Cgi = false;
http_response.fd = open(page.c_str(), O_RDONLY);
if (http_response.fd > 0)
{
struct stat st;
stat(page.c_str(), &st);
std::string line = "Content-Type: text/html";
line += END_LINE;
http_response.response_header.push_back(line);
http_request.size = st.st_size; // 如果这里不更新 size就会为0 因为前面并不会更新 因为请求的资源不存在
line = "Content-Length: ";
line += std::to_string(http_request.size);
line += END_LINE;
http_response.response_header.push_back(line);
}
}
void BuildOkResponse()
{
std::string line = "Content-Type: ";
line += Suffix2Desc(http_request.suffix);
line += END_LINE;
http_response.response_header.push_back(line);
line = "Content-Length: ";
if (http_request.Cgi){
line += std::to_string (http_response.response_body.size());
}
else {
line += std::to_string(http_request.size); //Get 不带uri -- 返回静态网页部分
}
line += END_LINE;
http_response.response_header.push_back(line);
}
组装和发送响应报文SendHttpResponse
- 非CGI机制处理:错误处理或者是请求静态网页资源,正文数据都在对应打开的文件缓冲区里
- CGI机制处理:正文数据都放在http_responsebody字段里
说明:
- 非CGI机制处理:其中没有调用write,或者send接口将对应文件里面的数据写入到套接字里面的原因是:套接字,资源文件本质都是一个被打开的文件,都有对应的内核级别的文件缓冲区;我们只需将一个内核文件缓冲区的数据直接拷贝到另一个内核文件缓冲区即可;这也是为什么非CGI机制时,我们不采取将对应文件里面的数据读入到body字段的原因,因为这多了一个步骤,多此一举(且body属于用户级缓冲区),而io效率是很慢的,所以不采取这种方案
void SendHttpResponse()
{
send(sock, http_response.status_line.c_str(), http_response.status_line.size(), 0);
for(auto &iter : http_response.response_header)
{
send(sock, iter.c_str(), iter.size(), 0);
}
send(sock, http_response.blank.c_str(), http_response.blank.size(), 0);
//std::cout << "debug: " << http_response.response_body.c_str() << std::endl;
if (http_request.Cgi){
auto &response_body = http_response.response_body;
int total = 0;
int size = 0;
const char * start = response_body.c_str();
while (total < response_body.size() && (size = send(sock, start + total, response_body.size() - total, 0) ) > 0){
total += size;
}
}
else{
sendfile(sock, http_response.fd, nullptr, http_request.size);
close(http_response.fd);
}
}
错误处理
错误分类
-
逻辑错误 – 该项在上面的运行逻辑已经处理了,例如: 404 400 等状态码
-
读取错误
-
写入错误
下面只讨论读读取和写入错误是什么且如何解决
读取错误
读取错误指的就是我们在从套接字中读取对端请求报文时,对端将套接字服务关闭,导致我们发生读取中段情况;
因为我们实际读取请求报文时分为以下5步,读取请求行,读取报头,解析状态行,解析报头,读取正文;如果读取请求行出现错误,后面的步骤就不需要进行了,读取报头出错,后面也不需要进行了,所以我们可以将读取和解析报文方法进行大概调整
处理: 及时止损,将连接关闭即可
- 读取请求行出错,后面4步不需要执行,关闭连接即可
- 读取请求行正常,读取请求报头出错,后面3步不需执行,关闭连接即可
- 读取请求正文出错,关闭连接即可
void RecvHttpRequest()
{
if ( (!RecvHttpRequestLine()) && (!RecvHttpRequestHeader()) )
{
ParseHttpRequestLine();
ParseHttpRequestHeader();
ParseHttpRequestBody();
}
}
写入错误
写入错误就是指当我们将响应报文写入到套接字接口时,对端直接将连接关闭的时,我们会收到SIGPIPE信号将我们进程终止掉(写端向一个已经关闭的连接进行写入,os会认为这是一种极其浪费资源的行为,直接发送信号将我们终止),而我们执行业务的是我们的线程,如果我们进程被终止掉了,就意味着我们服务直接挂了;
处理方式: 在服务器类中屏蔽掉SIGPIPE信号
// 该进程忽略SIGPIPE信号,不然会在线程进行写入时,对端忽然间关闭连接, 会导致server崩溃
signal(SIGPIPE, SIG_IGN);
项目测试
项目测试使用的软件:Postman
直接傻瓜式安装即可
网站首页请求测试
- 测试方法:
使用Postman 发送如下http报文
Get / HTTP/1.0
也可以使用telnet 直接发送上述内容,查看响应报文
服务器起来日志信息正常
业务正常,日志信息显示正确
网站首页我设置的是一个注册表单,响应正常
错误请求测试
404
测试方法:请求一不存在的资源;
例如发送如下http报文
Get /a/b/c HTTP/1.0
运行结果:
日志信息打印:
400
测试方法:使用不支持的请求方法进行资源请求
delete /a/b HTTP/1.0
日志信息打印正确:
响应报文无误:
500
500报错因为是服务器内部报错,需要我们自己构造出来
测试方法:
请求首页报文,人为错误,在构建响应阶段直接返回报错错误码
将该条信息放置于ProcessNonCgi()中
LOG(ERROR, "make SERVER_ERROR For Test");
return SERVER_ERROR;
日志信息打印:
响应无误:
GET方法上传数据测试
为了测试方便,将首页更换成计算器表
<!DOCTYPE html>
<html>
<head>
<meta charset = "UTF-8">
</head>
<body>
<form action="/test_cgi" method="get">
x:<input type="text" name="data_x" value="0">
<br>
y:<input type="text" name="data_y" value="1">
<input type="submit" value="提交">
</form>
<p>如果您点击提交,表单数据会被发送到cgi,进行处理</p>
</body>
</html>
测试方法:使用get方法 在表单位置传输账号密码
响应结果:
POST方法上传数据测试
通过正文部分传递参数
请求报文大致如下
Post /test.cgi HTTP/1.0
Content-Type: text/html
Content-Length: 11
a=100&b=200
日志信息打印:
响应构建无误
项目扩展及源码
项目源码
项目扩展:
- 支持长连接
- 搭建网络计算器
- 搭建博客
- 引入epoll
- …