HttpServer
HTTPServer项目是一个基于C++编写的简单的HTTP服务器实现,用于处理客户端的HTTP请求并提供相应的服务。该项目使用了Socket编程来实现服务器与客户端之间的通信,通过监听指定的端口并接受客户端连接,然后解析HTTP请求并生成对应的HTTP响应。
主要特点和功能包括:
- 基于线程池处理请求: 使用线程池技术,服务器在接收到客户端连接后,将客户端的请求任务提交到线程池中,由线程池中的线程来处理请求,这样可以有效地管理线程资源,避免频繁创建和销毁线程,提高服务器的性能和稳定性。
- 多线程处理请求: 采用多线程模型,每当有客户端连接到服务器时,创建一个新的线程来处理客户端的请求,这样可以实现并发处理多个客户端请求,提高服务器的并发性能。
- HTTP协议解析: 实现了基本的HTTP协议解析功能,能够解析客户端发送的HTTP请求,包括请求行、请求头部、请求正文等部分,并提取出其中的请求方法、URL、请求参数等信息。
- 静态文件服务: 支持对静态文件的访问,可以根据客户端请求的URL路径,读取服务器本地的静态文件,并将文件内容作为HTTP响应返回给客户端。
- 动态内容生成: 支持动态生成HTML内容,可以通过服务器端的CGI程序动态生成HTML页面,根据客户端请求的参数生成相应的页面内容,并将生成的HTML作为HTTP响应返回给客户端。
- 异常处理机制: 实现了简单的异常处理机制,对异常情况进行了处理,包括客户端连接异常、HTTP请求解析错误等,保证服务器的稳定性和可靠性。
HTTPServer项目是一个简单而完整的HTTP服务器实现,适合于学习和理解基本的网络编程和HTTP协议处理原理,也可以作为基础框架进行扩展,实现更复杂的功能和应用场景。
开发环境:Linux-Centos7 + vscode。
技术栈:
- 网络编程 (socket流式套接字,http协议)
- 多线程技术
- cgi技术
- 线程池
- 重定向
从零实现一个http服务器,先要了解网络协议栈和http协议相关知识。
网络协议栈
- 应用层:应用层负责处理特定的网络应用程序和用户数据的交互,提供了一些常见的网络服务,例如HTTP、FTP、SMTP等。在应用层中,数据被转换成特定的应用协议格式,以便应用程序之间的通信。
- 传输层:传输层负责提供端到端的数据传输服务,主要功能是确保数据的可靠传输和流量控制。常见的传输层协议有TCP(传输控制协议)和UDP(用户数据报协议)
- 网络层:网络层负责处理数据包在网络中的路由和转发,以实现不同网络之间的通信。它定义了IP(Internet Protocol)地址和路由选择等功能。常见的网络层协议包括IP协议、ICMP协议和ARP协议等。
- 数据链路层:链路层负责实现节点之间的直接通信,处理物理层面的数据传输问题,例如数据的分帧、错误检测和纠正等。常见的链路层协议有以太网协议、Wi-Fi协议和PPP协议等。
在数据传输的过程中,每经过一层网络协议栈都会添加对应的报头信息。经过层层封装通过网络传输到另一个主机,另一个主机自底向上交付时,又会层层解包最终将数据交付到对端。他们之间的通信是双向的。
此项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上HTTP报头再发送给客户端。
HTTP
HTTP(超文本传输协议)是一种用于传输超文本和其他网络资源的应用层协议,是互联网上数据传输的基础。
特点如下:
- 无连接性:HTTP协议是一种无连接的协议,每次请求都是独立的,服务器在处理完客户端的请求并发送响应后立即断开连接。
- 无状态性:HTTP协议是一种无状态的协议,即服务器不会记住之前的通信状态。每个请求都是独立的,服务器不会保存客户端的状态信息。
- 简单快速:HTTP协议使用简单,容易实现,且传输速度快。HTTP请求和响应的格式简单明了,只需要几个标识符和字段就能完成通信。
- 灵活:HTTP协议支持多种数据格式,例如文本、图片、音频、视频等,同时也支持多种传输编码和压缩方式,以便在不同的网络环境下实现更高效的数据传输。
- 可扩展性:HTTP协议采用了头部字段(Header)来传输元数据信息,使得协议具有较好的扩展性。可以通过添加新的头部字段来支持新的功能或者扩展现有功能。
HTTP协议本身不具备保存之前发送的请求或者响应的功能(无状态特点),就是每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。但是,速度方面的提升带来了用户体验方面的下降,比如保持用户的登陆状态。不保存之前发送请求和响应的功能,就会带来这一问题。比如当使用浏览器登录b站观看视频时,每次退出浏览器都要重新登录b站,体验很不好。解决这个问题,引入了Cookie技术,通过在请求和响应报文中写入Cookie信息来控制客户端的状态,同时为了保护用户数据的安全,又引入了Session技术,因此现在主流的HTTP服务器都是通过Cookie+Session的方式来控制客户端的状态的。
URL
URL(统一资源定位符)是一种统一资源标识符(URI)的特定类型,用于唯一地标识和定位互联网上的资源。URL提供了一种标准化的方式来指定互联网资源的位置,并且可以通过各种协议(如HTTP、HTTPS、FTP等)来访问这些资源。
一个标准的URL通常包含以下几个部分:
- 协议(Scheme) :指定访问资源所使用的协议,例如HTTP、HTTPS、FTP等。协议名后面跟着一个冒号和两个斜杠(例如http://、https://)。
- 主机地址(Host) :指定资源所在的主机的域名或IP地址。主机地址通常紧跟在协议之后,由一个或多个部分组成。
- 端口号(Port) :可选部分,指定用于访问资源的端口号。如果未指定,默认端口号会随着协议而变化(例如HTTP协议的默认端口号是80)。
- 路径(Path) :指定资源在服务器上的路径或位置。路径可以是一个文件名、文件夹名,或者更多的子路径。
- 查询参数(Query Parameters) :可选部分,用于向服务器传递参数,参数之间使用“&”符号分隔。
例如,下面是一个标准的浏览器URL示例:
https://www.example.com/path/to/resource?param1=value1¶m2=value2
- 协议是HTTPS。
- 主机地址是www.example.com。
- 路径是/path/to/resource。
- 查询参数包括param1和param2,它们分别的值是value1和value2
URL是互联网上资源的标准表示形式,它使得用户和应用程序能够方便地定位和访问网络上的各种资源。
HTTP请求与响应
HTTP请求协议格式:
HTTP协议请求由请求行、请求头部、空行和请求体组成。以下是HTTP请求的基本格式:
-
请求行:请求行包含了请求的方法、资源路径和协议版本,它们之间使用空格分隔。格式为:
Method Request-URI HTTP-Version
- Method:请求方法,常见的有GET、POST、PUT、DELETE等。
- Request-URI:请求的资源路径,指定了客户端想要访问的资源在服务器上的位置。
- HTTP-Version:HTTP协议的版本号,常见的有HTTP/1.1和HTTP/2。
例如:
GET /index.html HTTP/1.1
-
请求报头:请求头部包含了客户端向服务器传递的附加信息,每个头部字段都由字段名和字段值组成,中间用冒号分隔。不同的头部字段用换行符分隔。
例如:
Host: www.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
-
空行:空行用于分隔请求头部和请求体,通常为空行的存在表示请求头部的结束。
-
请求正文:对于POST请求等包含请求体的请求方法,请求体包含了客户端向服务器提交的数据。请求体的格式和内容根据具体的应用场景和需求而定。
HTTP响应协议格式:
HTTP协议响应由状态行、响应头部、空行和响应体组成。以下是HTTP响应的基本格式:
-
响应行:状态行包含了响应的HTTP协议版本、状态码和状态消息,它们之间使用空格分隔。格式为:
cssCopy codeHTTP-Version Status-Code Reason-Phrase
- HTTP-Version:HTTP协议的版本号,通常是HTTP/1.1或HTTP/2。
- Status-Code:响应的状态码,表示服务器对请求的处理结果。常见的状态码包括200(成功)、404(未找到)和500(服务器内部错误)等。
- Reason-Phrase:状态码的文本描述,用于说明状态码的含义。
例如:
HTTP/1.1 200 OK
-
响应报头:响应头部包含了服务器返回的附加信息,每个头部字段都由字段名和字段值组成,中间用冒号分隔。不同的头部字段用换行符分隔。
例如:
Content-Type: text/html; charset=utf-8 Content-Length: 1234
-
空行:空行用于分隔响应头部和响应体,通常为空行的存在表示响应头部的结束。
-
响应正文:响应体包含了服务器向客户端返回的数据。响应体的格式和内容根据具体的应用场景和需求而定。
一张图说明HTTP整个请求和响应的过程
HTTP 其他细节
-
HTTP 常见请求方法:
方法 说明 GET 用于请求获取指定资源的数据。GET请求通常用于获取页面、图片、文本等资源,而不会对服务器的状态产生影响,也不会改变资源的状态 POST :用于向服务器提交数据,常用于提交表单数据、上传文件等操作。POST请求会将数据作为请求体发送给服务器,通常用于创建新资源或者修改服务器上的资源状态。 PUT 用于向服务器传送数据,通常用于更新或者替换服务器上的资源。PUT请求会将指定的资源的全部内容替换为请求中的数据。 DELETE 用于删除服务器上的指定资源。DELETE请求会删除指定的资源,常用于删除服务器上的文件、记录等。 - HTTP的请求方法中最常用的就是GET方法和POST方法,其中GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器,但实际GET方法也可以用来上传数据,比如百度搜索框中的数据就是使用GET方法提交的。
- GET方法和POST方法都可以带参,其中GET方法通过URL传参,POST方法通过请求正文传参。由于URL的长度是有限制的,因此GET方法携带的参数不能太长,而POST方法通过请求正文传参,一般参数长度没有限制。
-
HTTP状态码
HTTP状态码是服务器响应客户端请求时返回的一个三位数字,用于表示请求的处理结果。常见的HTTP状态码分为五个大类,具体如下:
- 1xx:信息性状态码
1xx状态码表示服务器已经接收到请求,但是还需要进一步的处理或者等待客户端发送额外的信息。例如:
- 100 Continue:指示客户端可以继续发送请求体(在请求头包含 Expect: 100-continue 时使用)。
- 101 Switching Protocols:服务器已经切换到不同的协议,如HTTP/1.1到WebSocket。 - 2xx:成功状态码
2xx状态码表示服务器成功处理了客户端的请求。例如:
- 200 OK:请求成功,服务器返回请求的内容。
- 201 Created:请求已经成功创建了新的资源。
- 204 No Content:服务器成功处理了请求,但不需要返回任何内容。 - 3xx:重定向状态码
3xx状态码表示客户端需要采取进一步的操作才能完成请求。例如:
- 301 Moved Permanently:请求的资源已被永久移动到新的URL。
- 302 Found:请求的资源已被临时移动到新的URL。
- 307 Temporary Redirect:请求的资源临时重定向到新的URL,客户端应该保持原始请求方法。 - 4xx:客户端错误状态码
4xx状态码表示客户端的请求包含错误或者无法完成。例如:
- 400 Bad Request:客户端发送的请求有语法错误。
- 404 Not Found:请求的资源不存在。 - 5xx:服务器错误状态码
5xx状态码表示服务器在处理请求时发生了错误。例如:
- 500 Internal Server Error:服务器内部发生了错误,无法完成请求。
- 503 Service Unavailable:服务器当前无法处理请求,通常是因为服务器过载或者正在维护。
-
HTTP常见的报头
Content-Type:数据类型(text/html等)。 Content-Length:正文的长度。 Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。 User-Agent:声明用户的操作系统和浏览器的版本信息。 Referer:当前页面是哪个页面跳转过来的。 Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。 Cookie:用户在客户端存储少量信息,通常用于实现会话(session)的功能。
封装套接字
写一个http服务器,少不了socket,这里基于TCP协议封装一个socket。
使用懒汉模式将TcpServer
设计成为单例模式,保证此类只能实例化一个对象。
关于单例模式。参考博客:单例模式-CSDN博客
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "log.hpp"
// 单例模式的TcpServer
class TcpServer
{
private:
const static int backlog = 20;
TcpServer(uint16_t port) : _port(port), _listen_sock(-1) {}
TcpServer(const TcpServer &st) = delete;
public:
static TcpServer *GetInstance(int port)
{
// 静态互斥锁使用宏初始化 不用destory了
static pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;
// 懒汉模式 双检查加锁 提高效率 避免每次获取对象都要加锁
if (_ins == nullptr)
{
pthread_mutex_lock(&mutx);
if (_ins == nullptr)
{
_ins = new TcpServer(port);
}
pthread_mutex_unlock(&mutx);
}
return _ins;
}
// 创建套接字
int Socket()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock <= 0)
{
exit(1);
}
//允许在同一端口上启动服务器,即使之前的连接处于 TIME_WAIT 状态
int opt = 1;
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return _listen_sock;
}
// bind
void Bind()
{
struct sockaddr_in src;
memset(&src, 0, sizeof(src));
src.sin_family = AF_INET;
src.sin_port = htons(_port);
src.sin_addr.s_addr = INADDR_ANY;//直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。
int bind_ret = bind(_listen_sock, (struct sockaddr *)&src, sizeof(src));
if (bind_ret < 0)
{
exit(2);
}
}
// isten
void Listen()
{
int listen_ret = listen(_listen_sock, backlog);
if (listen_ret < 0)
{
exit(3);
}
}
~TcpServer()
{
//关闭套接字
if (_listen_sock >= 0)
{
close(_listen_sock);
}
}
private:
uint16_t _port; // 端口号
int _listen_sock; // 监听套接字
static TcpServer *_ins; // 单例模式对象
};
TcpServer *TcpServer::_ins = nullptr;
封装HTTP服务类
基于TcpServer
类,写一个HttpServer
类,构造HttpServer
对象时必须指定其端口号进行初始化。
调用start方法,让服务器不停的接收连接请求。接收到请求,创建线程进行处理(比如读取请求,解析请求,构建响应等等)
当接收到一个请求时,服务器可以立即创建一个新的线程来处理这个请求。这个新线程会执行请求的处理逻辑,并生成响应。使用这种方式,每个请求都会对应一个新的线程,这意味着服务器需要为每个请求分配一个独立的线程资源。这样的做法会导致线程的创建和销毁开销较大,特别是在高并发环境下可能会影响服务器的性能。
另一种做法是使用线程池。当接收到一个请求时,服务器会将请求相关的处理逻辑封装成一个任务,并将这个任务推送到线程池的任务队列中。线程池中的空闲线程会从任务队列中取出任务并执行。使用线程池的好处是可以避免频繁创建和销毁线程,提高了服务器的性能和效率。线程池能够控制线程的数量,并且能够重复利用已有的线程资源,减少了线程创建和销毁的开销。
下面使用第一种方法,当接收到一个请求后,就创建线程处理。这样方便测试。后面会引入线程池。(不创建线程也可以,创建子进程进行处理也行,但是创建一个进程比创建一个线程带来开销更大了。这里采用创建线程的方案。)
#pragma once
#include <pthread.h>
#include <signal.h>
#include "Tcp_server.hpp"
#include "log.hpp"
#include "Protocol.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
using std::cout;
using std::endl;
class HttpServer
{
public:
HttpServer(uint16_t port)
: _port(port)
{
TcpServer *_tcp_server = TcpServer::GetInstance(_port);
_sock = _tcp_server->Socket();
_tcp_server->Bind();
_tcp_server->Listen();
signal(SIGPIPE, SIG_IGN); // 信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,可能直接崩溃server
LogMessage(NORMAL, "HttpServer init successful socket = %d",_sock);
}
void Start()
{
while (1)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
continue;
}
LogMessage(DEBUG,"GET A NEW LINK SOCK = %d",sock);
// 创建线程 处理任务 使用fork创建子进程 也可以
// 不创建线程的话,主线程只负责accept,无法处理其他任务 就会阻塞
pthread_t tid;
pthread_create(&tid, nullptr, CallBack::RecvHttpRequst, (void *)&accept_ret); // 这里的socket一定是accept之后的套接字信息
pthread_detach(tid);//让线程执行完后自己回收资源,主线程不用在join了。
}
}
~HttpServer()
{
}
private:
uint16_t _port; // 端口
int _sock;
};
日志编写
服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。
直接写一个日志函数进行处理
// 日志功能
#pragma once
#include <iostream>
#include <ctime>
#include <stdio.h>
#include <stdarg.h>
#define DEBUG 0
#define NORMAL 1
#define WARING 2
#define ERROR 3
#define FATAL 4
#define FILE_NAME "./log.txt"
//日志等级
const char *gLevelMap[] = {
"DEBUGE",
"NORMAL",
"WARING",
"ERROR",
"FATAL"};
// 完整的日志功能,至少包括: 日志等级 时间 支持用户自定义(日志内容, 文件行,文件名)
void LogMessage(int level, const char *format, ...)
{
// 日志标准部分
char stdbuffer[1024];
time_t get_time = time(nullptr);
// 标准部分的等级和时间
struct tm *info = localtime(&get_time);
//格式化输出
snprintf(stdbuffer, sizeof(stdbuffer), "[%s]|[%d:%d:%d]", gLevelMap[level], info->tm_year + 1900, info->tm_mon + 1, info->tm_mday);
// 日志自定义部分
char logbuffer[1024];
// 获取可变参数列表
va_list valist;
// 为 num 个参数初始化 valist
va_start(valist, format);
vsnprintf(logbuffer, sizeof(logbuffer), format, valist);
// 清理为 valist 保留的内存
va_end(valist);
// 打印完整的日志信息(可重定向到日志文件中)
std::cout << stdbuffer << logbuffer << std::endl;
//把日志信息记录到日志文件中
// FILE* fp = fopen(FILE_NAME,"a");
// fprintf(fp,"%s %s\n",stdbuffer,logbuffer);
// fclose(fp);
}
调用上面的日志会输出如下信息:
[NORMAL]|[2024:3:20]HttpServer init successful socket = 3
日志 可以使用现成的库。关于日志库,没有做多少了解,这里就自己简单的写了一个源文件。
到此 HttpServer
的套接字相关部分就写完了,后面就是关于线程函数的编写和整个协议处理过程了。
线程回调函数
线程函数拿到套接字信息,读取到http请求整个处理过程分为以下4个过程
- 读取http请求
- 解析http请求
- 构建http响应
- 发送http响应到客户端。
class CallBack
{
public:
// 读取请求
static void *RecvHttpRequst(void *args)
{
int sock = *(int *)args;
EndPoint *ep = new EndPoint(sock);
ep->RecvHttpRequest(); // 读取http请求 && 解析http请求
if (!ep->IsStop())
{
ep->BuildHttpResponse(); // 构建响应
ep->SendHttpResponse(); // 发送响应
}
else
{
LogMessage(WARING, "RECV ERROR ,STOP BUILD AND SEND");
}
delete ep;
close(sock);
return nullptr;
}
};
协议处理
处理协议,后续还需要将响应信息发回给客户端,这里设计两个类,用来存放http请求和响应的属性字段。
-
请求类 && 响应类
struct HttpRequest { std::string request_line; // 请求行 std::vector<std::string> request_header; // 请求报文 std::string blank = "\r\n"; // 请求空行 std::string request_body; // 请求正文 // 解析请求行 std::string method; // 请求方法 std::string uri; // 请求uri std::string version; // http版本 std::string suffix; // 请求资源后缀 // 解析请求报文 std::unordered_map<std::string, std::string> header_kv; int content_length; // 请求正文字节数 // GET /a/b.index.html?x=100&y=100 http1.0 std::string path; // 请求路径(/a/b.index.html) std::string quer_string; // 请求参数(x=100&y=100) bool cgi = false; // 是否使用cgi机制 int size; // 目标资源文件大小 }; // http响应 struct HttpResponse { std::string status_line; // 状态行 std::vector<std::string> response_header; // 响应报文 std::string blank = "\r\n"; // 响应空行 std::string respinse_body; // 响应正文 int status_code; // 响应状态码 int fd; // 目标资源文件描述符 };
下面就是整个http协议处理的核心了。设计类EndPoint,提供整个http协议的请求和解析,以及构建响应和发回方法。
EndPoint类只需要四个字段即可,分别是套接字信息,和http请求,http响应,以及进行错误处理的stop字段(当建立连接后,请求时退出,或者就单纯建立请求,什么也不干,就不用继续后续的操作了。)
class EndPoint
{
private:
int _sock; // accept接收到套接字
HttpRequest _http_request; // http请求
HttpResponse _http_response; // http响应
bool _stop = false; // 读取是否中断
}
EndPoint类在设计的时候,向外提供四个几口即可,就是线程回调函数中处理http请求的的四个过程。其他函数,无需暴露出来。私有化即可。
EndPoint类框架如下:
class EndPoint
{
public:
EndPoint(int sock) : _sock(sock) {}//构造函数
void RecvHttpRequest(); // 读取请求
void BuildHttpResponse(); // 构建响应
void SendHttpResponse(); // 发送响应报文
bool IsStop(); //读取是否中断
~EndPoint(); //析构函数
private:
bool RecvHttpRequestLine();//读取http请求行
bool RecvHttpRequestHeader();// 读取请求报头和空行
void ParseHttpRequestLine();// 解析请求行
void ParseHttpRequestHeader(); // 解析请求报文
bool IsNeedRecvHttpRequestBody(); // 判断是否需要读取请求正文
void RecvHttpRequestBody(); //读取请求正文
bool RequesIstAllow(); // 请求合法性
bool ResourceExist(); // 判断请求资源是否存在
void ResourceSuffix(); // 获取请求数据类型 根据后缀suffix 进行确定
void BuildHttpResponseHelper();//构建响应 根据状态码构建不同的响应
void BuilOkResponse();//构建状态码无误的响应
void BuilErrorResponse(); // 构建错误响应报头和正文
int ProcessNoCGI(); // 非CGI返回静态网页即可
int ProcessCGI();// GET带参或者POST方法请求时需要使用CGI机制
private:
int _sock; // accept接收到套接字
HttpRequest _http_request; // http请求
HttpResponse _http_response; // http响应
bool _stop = false; // 读取是否中断
}
按照http协议处理的顺序一一实现
读取http请求
读取http请求进一步划分为以下几步:
- 读取请求行和请求报头
- 解析请求行
- 解析请求报头
- 读取正文
如果在读取请求行和报头时出现中断,后续的处理不用继续了,将_stop
属性更新为ture即可。
读取完请求行和请求报头,根据请求行的字段,包括请求方法(GET,POST),URI,和http版本进行后续处理。
如果时POST方法才需要读取正文,在读取正文时会判断,在代码层面上会体现出来。
void RecvHttpRequest()
{
// 读取请求行 // 读取请求报头
if ((!RecvHttpRequestLine()) && (!RecvHttpRequestHeader()))
{
ParseHttpRequestLine(); // 解析请求行
ParseHttpRequestHeader(); // 解析请求报头
RecvHttpRequestBody(); // 读取正文
}
}
读请求行
HTTP请求行包括三个部分,Method Request-URI HTTP-Version
MTEHOD
:请求方法
Request-URI
:通常是一个URL,指示客户端要访问的资源的路径
HTTP-Version
是所使用的HTTP协议版本,通常是HTTP/1.0 或 HTTP/1.1。
比如读取一个http请求行为:
GET /index.html HTTP/1.0
bool RecvHttpRequestLine()
{
std::string &line = _http_request.request_line;
// 读取请求行 http不同平台下的分隔符可能不一样,可能是\r 或 \n 在或者\r\n
// 在Util类中提供一个按行读取的方法进行解耦
int read_size = Util::ReadLine(_sock, line);//按行读取
if (read_size > 0)
{
line.resize(line.size() - 1);
}
else
{
_stop = true;
}
return _stop;
}
Util类中按行读取的方法:
需要注意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数进行读取,因为不同平台下的行分隔符可能是不一样的,可能是\r、\n或者\r\n。
因此我们这里需要自己写一个ReadLine函数,以确保能够兼容这三种行分隔符。我们可以把这个函数写到一个工具类当中,后续编写的处理字符串的函数也都写到这个类当中。
ReadLine函数的处理逻辑如下:
-
从指定套接字中读取一个个字符。
-
如果读取到的字符既不是\n也不是\r,则将读取到的字符push到用户提供的缓冲区后继续读取下一个字符。
-
如果读取到的字符是\n,则说明行分隔符是\n,此时将\npush到用户提供的缓冲区后停止读取。
-
如果读取到的字符是\r,则需要继续窥探下一个字符是否是\n,如果窥探成功则说明行分隔符为\r\n,此时将未读取的\n读取上来后,将\npush到用户提供
的缓冲区后停止读取;如果窥探失败则说明行分隔符是\r,此时也将\npush到用户提供的缓冲区后停止读取。
也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把\npush到了用户提供的缓冲区当中,相当于将这三种行分隔符统一转换成了以\n为行分隔符,只不过最终我们把\n一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的\n,需要后续自行将其去掉。
class Util
{
public:
static int ReadLine(int sock, std::string &out)
{
char ch = '0';
while (ch != '\n')
{
ssize_t recv_size = recv(sock, &ch, 1, 0); // 每次读取一个字符
if (recv_size > 0)
{
if (ch == '\r')
{
// 窥探下一个字符是否为\n MSG_PEEK可以窥探缓冲区中下一个字符 能做到只看不读
recv(sock, &ch, 1, MSG_PEEK);
if (ch == '\n')
{
recv(sock, &ch, 1, 0); // 读走\n
}
else
{
ch = '\n'; // 不是\n设置为\n
}
}
out.push_back(ch);
}
else if (recv_size == 0)// 对方关闭连接
{
return 0;
}
else// 读取失败
{
return -1;
}
}
return out.size(); // 返回读取到的个数
}
};
读取请求报头
HTTP报头格式每行都是键值对,键值对之间用:
分隔,每个报头都以\n
做为分隔符结束。
所以存储http请求报头时采用vector容器进行存储
std::vector<std::string> request_header; // 请求报文
Host: www.example.com
Conteng-Length: 1024
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;
}
// 将读取到的每一行插入到request_header中(去掉\n)
line.resize(line.size() - 1);
_http_request.request_header.push_back(line);
}
return _stop;
}
解析请求行
读取完请求行和请求报头就要解析请求行和请求报头了,因为要根据请求行的请求方法method确定是否需要读取请求正文(post才需要读取)
解析请求行就时把请求行的三个属性提取出来即可
比如:比如:GET /index.html HTTP/1.0
解析为method:GET
,URI = index.html
,http_version = HTTP/1.0
这里使用std::stringstream
进行拆分。
std::stringstream
std::stringstream
提供了对字符串的输入、输出和格式化的功能,可以方便地进行字符串的处理、解析和拼接
需要注意的时http协议不区分请求的大小写,所以读取上来时全部统一转换为大写进行处理。
// 解析请求行
void ParseHttpRequestLine()
{
std::string &line = _http_request.request_line;
// 通过stringstream拆分请求行
// eg: GET / HTTP/1.1 拆分为三部分 请求方法 url 和http版本
std::stringstream ss(line);
ss >> _http_request.method >> _http_request.uri >> _http_request.version;
// http协议不区分大小写 这里请求方法全部统一为大写
auto &method = _http_request.method;
std::transform(method.begin(), method.end(), method.begin(), ::toupper);
}
解析请求报头
将K/V请求报头解析完存储到unordered_map中。报头中使用:
做为分隔符,在Util类中提供cutString方法将报头切割出来。
比如:Conteng-Length: 1024 切割为key = Content-Length ,value = 1024。
// 解析请求报文
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
// 将读到的报文 以K/V结构存储到unordered_map中
for (auto &iter : _http_request.request_header)
{
// 报文中的数据以: 做为分隔符 以: 切割子串
if (Util::cutString(iter, key, value, SEP))
{
_http_request.header_kv.insert({key, value});
}
}
}
curString
static bool cutString(const std::string &target, std::string &sub1_str, std::string &sub2_str, const std::string sep) { size_t pos = target.find(sep); if (pos != std::string::npos) { sub1_str = target.substr(0, pos); sub2_str = target.substr(pos + sep.size()); // 默认到结尾 return true; } return false; }
读取请求正文
读取之前先进行判断请求方式是否为post,post方法读取请求正文。
需要根据报头中的信息获取正文中的字节数。根据正文的字节数,确定从套接字中recv读取个数。
这里一次读取一个字节即可。
比如正文是:Hello World
,http协议会根据正文内容自动填充报头中的字节数。报头中的Content-Length就是存储正文字节数的属性。会自定被填充为11,然后从套接字读取11字节即可。
// 判断是否需要读取请求正文
bool IsNeedRecvHttpRequestBody()
{
// POST方法才需要读取正文
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())
{
_http_request.content_length = atoi(iter->second.c_str());
}
return true;
}
return false;
}
// 请求正文
void RecvHttpRequestBody()
{
// 判断正文是否有需求读取
if (IsNeedRecvHttpRequestBody())
{
int length = _http_request.content_length;
std::string &body = _http_request.request_body;
char ch = 0;
while (length)
{
ssize_t recv_size = recv(_sock, &ch, 1, 0);
if (recv_size > 0)
{
body += ch;
length--;
}
else
{
break;
}
}
LogMessage(DEBUG,"REQUEST BODY = %s",_http_request.request_body.c_str());
}
}
构建响应
HTTP协议的请求和解析工作做完后,就要构建响应了。
构建响应之前要先做以下几部分的检查:
- 判断请求的合法性
- 确定访问资源的路径
- 判断资源是否存在 && 判断资源属性
- 确定请求数据类型
- 确定响应机制
当检查和确认完了之后,根据响应状态码构建响应报头和响应正文。(响应状态码在检查的时候会被设置好)。
整个流程代码如下:
void BuildHttpResponse()
{
int &code = _http_response.status_code;
// 判断请求的合法性
if (RequesIstAllow())
{
// 指定web根目录
std::string path = _http_request.path;
_http_request.path = WWWROOT;
_http_request.path += path;
// 访问默认界面 比如http://8.130.46.184:8081 请求路径为wwwroot/
if (_http_request.path[_http_request.path.size() - 1] == '/')
{
_http_request.path += HOME_PAGE;
code = OK;
}
if (ResourceExist()) // 资源存在
{
ResourceSuffix(); // 获取资源类型
// 确定响应机制 CGI or NOCGI(返回静态网页即可)
if (_http_request.cgi)
{
code = ProcessCGI();
}
else
{
code = ProcessNoCGI();
}
}
}
//检查完毕构建响应
BuildHttpResponseHelper();
}
判断请求合法性
目前之处理了两种请求方式,GET和POST。以这两种为例。其余都以非法请求处理。
GET 请求方法:
GET请求方式就是通过浏览器输入网址的方式来请求的。GET请求方式可以分为不带惨和带参的两种方式,这两种方有不同的处理机制
-
GET 方法不带参数
- 比如在浏览器网址中输入
www.baidu.com
浏览器会自动加上请求协议https:://
进行访问
- 比如在浏览器网址中输入
- 通过这中方式就是请求到百度的默认首页(index.html) 百度的服务器就会给我们返回他的静态网页。
- 默认首页index.html,一般在服务器进程的wwwroot目录下。通常用于存放 Web 服务器所提供的网站资源文件。这个目录通常包含网站的 HTML 文件、CSS 样式表、JavaScript 脚本、图像文件以及其他静态资源等。(这里就是前端相关的技术了,前端开发者写完网页就会部署到服务器的wwwroot目录下,为用户提供服务。而后端处理的东西对用户是隐藏的)
- GET 不带参数时,需要指定访问资源的路径即可。这里需要自己拼接一下
> 指定web目录
>
> 访问默认首页时,比如:`https://www.baidu.com/`请求路径就是默认wwwroot/index.html
>
> WWWROOT 是一个宏,`#define WWWROOT "wwwroot"`
>
> 然后根据请求路径最后一个字符拼接上给目录下的index.html即可
```
// 指定web根目录
std::string path = _http_request.path;
_http_request.path = WWWROOT;
_http_request.path += path;
// 访问默认界面 比如http://8.130.46.184:8081 请求路径为wwwroot/
if (_http_request.path[_http_request.path.size() - 1] == '/')
{
_http_request.path += HOME_PAGE;
code = OK;
}
```
-
GET 方法带参
- GET请求可以通过URL的查询字符串(query string)来传递参数。例如,
http://example.com/page?param1=value1¶m2=value2
,这里的参数会以键值对的形式附加在URL后面,用?
分隔URL和参数,不同参数之间用&
分隔。这是http协议的规定。 - 访问百度的时候也可带参
https://www.baidu.com/a/b?x=100&y=200
。只不过没有该资源。如图:
- GET请求可以通过URL的查询字符串(query string)来传递参数。例如,
- GET方法带参,需要使用CGI机制进行处理。CGI(Common Gateway Interface)是一种通用的网关接口,用于在Web服务器和外部程序之间传递信息。它允许Web服务器调用外部程序来处理客户端的HTTP请求,并将结果返回给客户端。在后面的处理机制中会详细介绍的。这里只需要将CGI标志位设置为true即可,表示使用CGI机制。
POST 请求方法
post请求方法也是需要使用CGI机制进行处理的,与GET请求不同,POST请求将数据包含在请求正文(Request Body)中,而不是在URL中传递参数。POST请求常用于提交表单数据、上传文件等操作。
如果是post方法,指定CGI程序的路径将CGI标志位设置为true即可。
// 请求合法性
bool RequesIstAllow()
{
int &code = _http_response.status_code;
if (_http_request.method == "GET") // 通过url传参
{
// 路径和参数使用?分隔 默认是没有参数的。
size_t pos = _http_request.uri.find('?');
if (pos != std::string::npos)
{
// 切割url 将路径和参数提取
Util::cutString(_http_request.uri, _http_request.path, _http_request.quer_string, "?");
// GET 方法带参了 也是也CGI机制处理
_http_request.cgi = true;
}
else
{
_http_request.path = _http_request.uri;//请求资源路径
}
}
else if (_http_request.method == "POST")
{
// 使用CGI机制处理 CGI程序的路径就在uri里面
_http_request.cgi = true;
_http_request.path = _http_request.uri;
}
else // 非法请求
{
LogMessage(DEBUG, "NOT ALLOW REQUEST");
code = BADN_REQUEST;
return false;
}
return true;
}
判断资源是否存在
请求资源的路径以及在判断合法性的时候确定了。路径已经确定就要判断该路径下资源是否存在。
比如访问百度服务器时,随便输入一个路径,百度服务器就会返回一个404的页面
使用stat判断请求路径下的资源是否存在
stat
函数用于获取文件的状态信息,包括文件大小、修改时间、访问权限等。它通常用于检查文件是否存在、获取文件的属性等操作。
在判断资源文件的属性时,如果是目录返回该目录下的index.html即可。
比如:这里以我的服务器为例http://8.130.46.184:8081/wwwroot
资源文件为wwwroot 是一个目录,拼接一下该目录下的index.html即可同时获取一下文件的大小,后面像客户端发送时需要知道文件的大小。
如果是一个可执行程序,要使用CGI机制处理。将CGI标志位设置为true即可。
bool ResourceExist()
{
struct stat statbuf; // 存储文件属性struct
auto &code = _http_response.status_code;
if (stat(_http_request.path.c_str(), &statbuf) == 0)
{
// 资源存在->判断资源文件的属性(目录/.exe都有可能 )
if (S_ISDIR(statbuf.st_mode)) // 目录 返回该目录下的index.html即可
{
_http_request.path += '/'; // path = wwwroot/a/b 要加分隔符
_http_request.path += HOME_PAGE;
stat(_http_request.path.c_str(), &statbuf); // 更新一下资源文件 获取大小
}
else if ((statbuf.st_mode & S_IXUSR) || (statbuf.st_mode & S_IXGRP) || (statbuf.st_mode & S_IXOTH)) //.exe 使用CGI机制进行处理
{
_http_request.cgi = true;
}
else if(S_ISREG(statbuf.st_mode))//普通文件
{
//....
}
else
{
LogMessage(ERROR, "UNKONW RESOURCE FILE STAT");
}
_http_request.size = statbuf.st_size; // 资源文件的大小
}
确定请求数据类型(Content-Type)
Content-Type(内容类型),一般是指网页中存在的 Content-Type,用于定义网络文件的类型和网页的编码,决定浏览器将以什么形式、什么编码读取这个文件,这就是经常看到一些 PHP 网页点击的结果却是下载一个文件或一张图片的原因。
常见的如下:
数据类型 | 文件扩展名 |
---|---|
text/html | .html |
text/css | .css |
application/javascript | .js |
application/x-jpg | .jpg |
application/xml | .xml |
更具体的见链接:HTTP content-type | 菜鸟教程 (runoob.com)
// 获取请求数据类型 根据后缀suffix 进行确定
void ResourceSuffix()
{
size_t pos = _http_request.path.rfind(".");
if (pos == std::string::npos)
{
_http_request.suffix = ".html";
}
else
{
_http_request.suffix = _http_request.path.substr(pos);
}
}
确定响应机制
根据CGI标志位确定响应机制。
-
非CGI机制
- 返回静态网页即可
- 打开请路径下的静态网页,获取文件描述符。后面send的时候从这个文件描述符send就可以了。
-
int ProcessNoCGI() { _http_response.fd = open(_http_request.path.c_str(), O_RDONLY); if (_http_response.fd < 0) { return NOT_FOUND; } return OK; }
-
CGI机制
-
只有当GET 方法待参和POST方法才需使用CGI机制处理
-
CGI的基本工作流程如下:
- 客户端发起 HTTP 请求
- Web 服务器接收请求
- 执行 CGI 程序
- CGI 程序处理请求
- CGI 程序生成响应
- Web 服务器发送响应
-
实际我们在进行网络请求时,无非就两种情况:
浏览器想从服务器上拿下来某种资源,比如打开网页、下载等。
浏览器想将自己的数据上传至服务器,比如上传视频、登录、注册等。
通常从服务器上获取资源对应的请求方法就是GET方法,而将数据上传至服务器对应的请求方法就是POST方法,但实际GET方法有时也会用于上传数据,只不过POST方法是通过请求正文传参的,而GET方法是通过URL传参的。
而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让HTTP或相关程序对该数据进行处理,比如用户提交的是搜索关键字,那么服务器就需要在后端进行搜索,然后将搜索结果返回给浏览器,再由浏览器对HTML文件进行渲染刷新展示给用户。
但实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,这些CGI程序可以用任何程序设计语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器。
整个流程如图:
CGI机制实现:
httpserver进程接收到一个请求之后,会创建一个线程来执行对应的业务逻辑,要调用CGI程序肯定要执行进程程序替换,如果让当前线程直接进行去替换的话,当执行完CGI程序后,httpServer整个程序也就结束了,这就意味着服务器挂了,后面再有新的请求无法处理了,而一个服务器是7*24小时不间断运行的。所以这里让线程执行fork创建子进程,让子进程进行进程程序替换,当前线程进行等待,获取CGI程序的处理结果和退出状态,根据CGI程序的退出状态更新响应的转态码(有可能处理错误,子进程异常退出)
而让父子进程(httpserver进程和cgi进程)之间进行数据交付,要使用进程间通信机制,父子间通信,使用匿名管道即可。
这里需要两个匿名管道,因为管道是单向通信的,而此处的逻辑是需要双方互相通信的,父进程交给子进程数据后,子进程还需要将处理结果返回给父进程。
创建完管道之后,关闭父子双方不需要的文件描述符。当进程程序替换后,进程的代码和数据会被替换,内核数据结构不会被替换,这里就需要注意替换完后CGI进程是拿不到管道文件描述符的,因为管道文件描述符属于进程的数据,会被替换掉,而管道文件属于内核数据结构,不会被替换,这就会导致替换后CGI无法读取管道文件里的数据,更无法向管道文件写会数据。
使用重定向解决这个问题,每个进程都会默认打开0,1,2号文件(即标准输入,标准输出和标准错误)。可以规定子进程从0号文件中读,1号文件中写,在进程程序替换前进行重定向。重定向后,CGI程序测试的时候不能使用cout进行测试了,使用cout就是向管道中写,cin就是从管道文件中读。可以使用cerr进行测试,向终端打印信息。
CGI程序也需要知道http请求方法:例如GET方法,http://example.com/page?param1=value1¶m2=value2
,这里的参数会以键值对的形式附加在URL后面,用?
分隔URL和参数,不同参数之间用&
分隔。CGI程序需要将不同的参数提取出来进行下一步操作。当然,也可以让httpServer处理完后通过发送给CGI。
这里采用导入环境变量的方式将请求方法导入环境变量中,CGI程序直接从环境变量中获取请求方法。如果是POST方法,则需要将请求正文的字节数也导入环境变量。父进程从管道写入请求正文数据,子进程通过环境变量导入正文字节数从管道文件中读取对应字节。
请求方法和正文字节数这种小字节可以使用环境变量导入,如果是正文数据,很大,还是要通过管道的。管道的大小也是有限的,如果正文数据太大, 可以分批向管道中写入。
图解详细的CGI机制的整个处理流程
int ProcessCGI()
{
std::string &bin = _http_request.path;//CGI程序路径
std::string &method = _http_request.method; // 请求方法
int code = OK; // 响应状态码
// 创建两个管道进行httpserver和CGI进程进行通信(父子进程进行通信)
// 管道是单向通信的 这里需要两个管道进行读写
int input[2];
int output[2];
if (pipe(input) < 0)
{
LogMessage(FATAL, "CREATE INPIPE FAIL");
code = 404;
return code;
}
if (pipe(output) < 0)
{
LogMessage(FATAL, "CREATE OUTPIPE FAIL");
code = 404;
return code;
}
// 创建子进程进程程序替换CGI程序
pid_t pid = fork();
if (pid == 0) // 子进程
{
close(output[1]);
close(input[0]);
// execl进程程序替换 不会替换环境变量 继续使用子进程的环境变量
std::string method_env = "METHOD="; // 导入METHOD环境变量
method_env += method;
putenv((char *)method_env.c_str());
if (method == "GET")
{
// GET 方法需要知道uri中的参数 通过环境变量导入 CGI程序使用getenv获取参数
std::string &query_string = _http_request.quer_string; // 请求参数
std::string query_string_env = "QUERY_STRING=";
query_string_env += query_string;
putenv((char *)query_string_env.c_str());
LogMessage(NORMAL, "METHOD = GET, ADD QUERY_STRING ENV");
}
else if (method == "POST")
{
// POST 方法需要知道请求正文中的字节数,也通过环境变量导入
std::string content_length_env = "CONTENG_LENGTH=";
content_length_env += std::to_string(_http_request.content_length);
putenv((char *)content_length_env.c_str());
LogMessage(NORMAL, "METHOD = POST, ADD CONTENG_LENGTH ENV");
}
else
{
//...
}
// 替换前重定向
dup2(output[0], 0);
dup2(input[1], 1);
// 进程程序替换,代码和数据都会被替换了,子进程拿到的管道文件描述符已经被替换走了,使用重定向让目标替换进行0读1写(每个进程都会打开0,1,2)
execl(bin.c_str(), bin.c_str(), nullptr);
exit(0);
}
else if (pid < 0)
{
LogMessage(FATAL, "create subprocess fail");
code = 404;
return code;
}
else // 父进程
{
close(output[0]);
close(input[1]);
if (method == "POST") // POST方法,需要父进程将正文中的参数通过管道发送给CGI程序
{
std::string &request_body = _http_request.request_body;
write(output[1], request_body.c_str(), request_body.size()); // 数据太大一次性可能写不完 待解决
}
// 父进程从管道中读取数据(CGI向管道中写数据了) 读完存到响应正文中 返回给客户端
char ch = 0;
while (read(input[0], &ch, 1) > 0)
{
_http_response.respinse_body.push_back(ch);
}
int status;
pid_t wait_pid = waitpid(pid, &status, 0);
if (wait_pid == pid && WIFEXITED(status)) // 等待成功
{
// #define WEXITSTATUS(status) (((status) & 0xff00) >> 8)
if (WEXITSTATUS(status) == 0)
{
code = OK;
}
else // 子进程异常退出
{
code = 400;
}
}
else // 等待失败
{
code = 500;
}
close(output[1]);
close(input[0]);
}
return code;
}
构建响应报头和响应正文
经过上面的检测和数据处理后,响应状态码已经被设置好了,先填充响应行,根据响应装态码构建响应报头和响应正文。这里只简单的做了正确和资源不存在的状态码处理。
填充响应行时,需要将状态码转换为转态描述,这里简单使用一个函数处理几个常见的:
完整的http转态码和状态描述参考”:HTTP 状态码 | 菜鸟教程 (runoob.com)
static std::string CodeToDesc(int code)
{
std::string desc;
switch (code)
{
case 200:
desc = "OK";
break;
case 400:
desc = "NOT FOUND";
break;
default:
desc = "UNKNOWN";
break;
}
return desc;
}
void BuildHttpResponseHelper()
{
int &code = _http_response.status_code;
LogMessage(DEBUG,"RESPONSE CODE = %d",code);
// 构建响应行
std::string &line = _http_response.status_line;
line += HTTP_VERSION;
line += " ";
line += std::to_string(code);
line += " ";
line += CodeToDesc(code);
line += "\r\n"; // 空行
// 构建响应正文,可能包括响应报头
// 根据状态码构建响应报头和响应正文
switch (code)
{
case OK:
BuilOkResponse();
break;
case NOT_FOUND:
BuilErrorResponse();
break;
default:
break;
}
}
构造正确响应报头和正文
正确的响应报文根据http协议响应规定填充即可。
void BuilOkResponse()
{
// 构建响应报头
std::string line = "Content-Type: ";
line += TypeToDesc(_http_request.suffix);
line += "\r\n";
_http_response.response_header.push_back(line);
line = "Content-Length: ";
if (_http_request.cgi)
{
line += std::to_string(_http_request.request_body.size());
}
else
{
line += std::to_string(_http_request.size);
}
line += "\r\n";
_http_response.response_header.push_back(line);
}
构建错误响应报头和正文
错误的响应返回404页面即可。如果CGI程序处理错误,异常退出,这些一般属于服务器内部错误,也要构建对象的错误页面。这种错误属于服务器内部错误.子进程异常退出,对应的管道文件描述符也被关闭了,而父进程可能还会继续从管道文件中读,此时如果管道发送了SIGPIPE信号给写入端,写入端的进程会因为SIGPIPE信号而终止。在服务器编程中,通常不希望因为管道的写端关闭而导致进程异常终止,因此忽略SIGPIPE信号是一种常见的做法。一般在初始化服务器的时候都会忽略此信号.
void BuilErrorResponse()
{
// 返回404页面即可
std::string path = WWWROOT;
path += "/";
path += PAGE_404;
_http_response.fd = open(path.c_str(), O_RDONLY);
if (_http_response.fd > 0)
{
struct stat statbuff;
stat(path.c_str(), &statbuff);
_http_request.size = statbuff.st_size;
std::string line = "Content-Type: text/html";
line += "\r\n";
_http_response.response_header.push_back(line);
line = "Content-Length: ";
line += std::to_string(_http_request.size);
_http_response.response_header.push_back(line);
}
}
发送响应报文
服务器向客户端发送响应时,按照http协议的规定,发送四部分即可
- 发送状态行
- 发送响应报文
- 发送空行
- 发送正文
发送正文时,采用sendfile发送,提高发送效率.
send和sendfile的区别:
-
sendfile:
sendfile
是一个高效的系统调用,它可以在两个文件描述符之间直接传输数据,而无需将数据从内核缓冲区复制到用户缓冲区。这使得sendfile
在发送大量数据时具有较高的性能。sendfile
主要用于在两个文件之间进行数据传输,通常用于将文件内容发送到网络套接字上,或将文件内容写入另一个文件。
-
send:
send
是常规的发送数据的系统调用,它将数据从用户空间复制到内核缓冲区,然后再从内核缓冲区发送到网络套接字。send
主要用于发送网络套接字上的数据,可以发送任意大小的数据,但可能会因为数据复制次数较多而降低性能。
图解:
// 发送响应报文
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); // 发送空行
// 发送正文(即请求报文下路径资源文件 这里使用sendfile发送,提高发送效率直接从内核层进行发送)
sendfile(_sock, _http_response.fd, nullptr, _http_request.size);
close(_http_response.fd);
}
错误处理
当客户端访问的资源或者非法请求时,上面已经处理过了。服务器还有一种错误就是读取错误,比如在读取请求的过程中,此时双方已经建立好了链接,服务器在调用recv从套接字读取信息时,读取出错,或者读取时对方断开了连接这种情况。都的属于读取错误。
当读取出错时,服务端没有读取到完整的http请求,那么服务器也就不需要继续执行后面的操作了。这种错误,在读取http请求时将stop标志位设置为true即可。说明服务器已经stop了,后续的动作无需进行,在线程回调函数中读取完http请求后判断一下服务器是否stop即可。
void HandlerRequest(int sock)
{
EndPoint *ep = new EndPoint(sock);
ep->RecvHttpRequest(); // 读取http请求
// 处理读取错误 建立请求后 什么也没请求 不用继续后续操作了
if (!ep->IsStop())
{
// ep->ParseHttpRequest(); // 解析http请求
ep->BuildHttpResponse(); // 构建响应
ep->SendHttpResponse(); // 发送响应
}
else
{
LogMessage(WARING, "RECV ERROR ,STOP BUILD AND SEND");
}
delete ep;
close(sock);
}
还有一种错误就是写入错误,在服务器向客户端发送http响应报文时,可能send失败,也有可能在send的时候,对方断开连接。
出现写入错误时,服务器也没有必要继续进行发送了,这时需要直接设置stop为true并不再进行后续的发送操作即可。
接入线程池
未使用线程池之前,当有新的连接时就会创建一个新线程,当有大量的请求同时来的时候,创建太多的线程,服务器很有可能扛不住挂掉。缓解服务器频繁创建线程可以使用线程池。
使用多线程和线程池的区别:
- 多线程: 在多线程模型中,通常会创建多个线程来处理并发的任务。每个客户端连接到服务器时,服务器会创建一个新的线程来处理该连接的请求。这种方式下,每个连接都对应一个新的线程,线程的数量与连接数量成正比。
- 线程池: 线程池是一种管理线程的机制,它维护一个固定数量的线程池。在本项目中,服务器在启动时会创建一个线程池,并预先分配一定数量的线程。当有客户端连接到服务器时,服务器会将该连接的处理任务提交给线程池中的空闲线程来执行。这种方式下,服务器可以限制线程的数量,避免因为连接数量过多导致线程资源耗尽,同时也减少了线程创建和销毁的开销。
总的来说,在本项目中,线程池是一种更有效管理线程资源的方式,它能够提高服务器的并发处理能力,减少资源的浪费,提高服务器的性能。
- 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
- 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
使用线程池也只能缓解服务器压力,当有大量的请求时,线程池中没有空闲线程时,请求就会推送到任务队列中,等待线程进行处理,这样客户端就会迟迟得不到响应。想彻底解决问题只能从硬件上下手。
任务类设计
当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。
#pragma once
#include "Protocol.hpp"
class Task
{
public:
Task() {}
Task(int sock) : _sock(sock) {}
//处理任务
void ProcessOn()
{
handler(_sock);//CallBack中重载了()
}
private:
int _sock;
CallBack handler; // 设置回调
};
CallBack类的设计
CallBack类重载了(),让Task调用CallBack对象以函数的方式进行调用。
class CallBack
{
public:
void operator()(int sock)
{
HandlerRequest(sock);
}
// 读取http请求
void HandlerRequest(int sock)
{
EndPoint *ep = new EndPoint(sock);
ep->RecvHttpRequest(); // 读取http请求
if (!ep->IsStop())
{
ep->BuildHttpResponse(); // 构建响应
ep->SendHttpResponse(); // 发送响应
}
else
{
LogMessage(WARING, "RECV ERROR ,STOP BUILD AND SEND");
}
delete ep;
close(sock);
}
};
线程池的设计
HTTPServer在整个应用程序中通常只需要一个线程池来处理客户端的请求,将线程池设计为单例模式。
线程池在创建的时候就已经初始化完成了,一次就创建了一批线程,让线程从任务队列中获取任务,当任务队列中没有任务时就会在对应的条件变量下进行等待,当主线程向任务队列推送任务时,就会唤醒一个线程取处理任务。
这里的任务队列使用的是std::queue
,不是线程安全的,多线程访问时需要自己进行加锁处理。
// 单例模式的线程池
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include "log.hpp"
static const int NUM = 5;
class ThreadPool
{
public:
//获取单例
static ThreadPool *GetInstance()
{
static pthread_mutex_t inst_mutex = PTHREAD_MUTEX_INITIALIZER;
if (_inst == nullptr)
{
pthread_mutex_lock(&inst_mutex);
if (_inst == nullptr)
{
_inst = new ThreadPool;
_inst->InitPthreadPool();
}
pthread_mutex_unlock(&inst_mutex);
}
return _inst;
}
//析构
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
//初始化线程池
bool InitPthreadPool()
{
for (int i = 0; i < _num; ++i)
{
pthread_t tid;
if (pthread_create(&tid, nullptr, ThreadRoutine, this) != 0)
{
LogMessage(FATAL, "CREATE THREAD POOL FAIL!!!!");
return false;
}
}
LogMessage(DEBUG, "CREATE THREAD POOL SUCCESS");
return true;
}
//推送任务
void PushTask(const Task &task)
{
Lock();
_task_queue.push(task);
Unlock();
ThreadWakeUp();
}
//取任务
void PopTask(Task& task)
{
task = _task_queue.front();
_task_queue.pop();
}
void Lock()
{
pthread_mutex_lock(&_mutex);
}
void Unlock()
{
pthread_mutex_unlock(&_mutex);
}
void ThreadWait()
{
pthread_cond_wait(&_cond, &_mutex);
}
void ThreadWakeUp()
{
pthread_cond_signal(&_cond);
}
bool TaskQueueIsEmpty()
{
return _task_queue.empty();
}
//线程回调函数
static void *ThreadRoutine(void *args)
{
ThreadPool *tp = (ThreadPool *)args;
while (true)
{
Task task;
tp->Lock();
while (tp->TaskQueueIsEmpty())
{
tp->ThreadWait();
}
tp->PopTask(task);
tp->Unlock();
// 处理任务
task.ProcessOn();
}
}
private:
std::queue<Task> _task_queue; // 任务队列
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _cond; // 条件变量
int _num; // 线程数量
static ThreadPool *_inst;
//构造
ThreadPool(int num = NUM) : _num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
ThreadPool(const ThreadPool &) = delete;
ThreadPool &operator=(const ThreadPool &) = delete;
};
ThreadPool* ThreadPool::_inst = nullptr;
到此整个服务器就写的差不多了。但是服务器一般都是需要24*7h提供服务的,也就是说这个进程需要一直运行, 他是一个前台进程,会占用终端,这个进程就会占用用户当前的终端或会话,使得用户无法在终端中执行其他命令,直到前台进程执行完毕或被暂停。
当前终端退出后,此进程就会退出。这很显然不合适。使用守护进程解决这个问题。
守护进程
守护进程(Daemon Process)是在后台运行的一种特殊类型的进程,通常在操作系统启动时自动启动,并且在操作系统关闭时自动终止。它们通常在后台运行,不会与用户交互,并且通常用于执行系统任务或提供服务。
简单的来说,前台进程就是和终端关联的进程,守护进程就是不和终端关联的进程。终端退出,进程也退出的就是前台进程,不退出的就是后台进程。
守护进程就是让当前进程和bash不在属于一个会话组,让自己独立成为一个会话组。使用setsid
系统调用就可以创建一个新的会话组,但是调用setsid
有一个前提就是调用setsid
的进程不能是守护组的组长。在命令行中 用管道创建一批进程,这一批进程可以成为一个进程组,组长般是第一个进程。防止调用setsid
是组长进程,使用fork创建子进程,让父进程退出,子进程调用setsid
,调用setsid后,调用进程会成为新会话的领头进程,并且不再和终端关联。
成为守护进程后,也就不能向终端打印信息了,Linux下有一个文件黑洞,在/dev/null
目录下。将进程的所有输出都重定向到该文件下即可。
#pragma once
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstdlib>
// 守护进程
void Daemon()
{
// 1.忽略SIGPIPE 和SIGCHLD信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 让父进程退出(守护进程不能是当前进程组的组长)
if (fork() > 0)
{
exit(0);
}
//3. 调用setsid让子进程成为守护进程
setsid();
//4. 守护进程不能在向终端中打印,Linux下dev/null文件是一个文件黑洞,所有文件都可以向这里显示,不影响终端正常运行
int devnull = open("/dev/null", O_RDONLY | O_WRONLY);
if (devnull > 0)
{
dup2(0, devnull);
dup2(1, devnull);
dup2(2, devnull);
close(devnull);
}
}
使用守护进程。
在main
函数中调用Daemon函数即可。一般main函数启动的时候就将当前进程转换为守护进程即可。
#include "Http_Server.hpp"
#include "Daemon.hpp"
#include <memory>
int main()
{
Daemon();//使用守护进程
std::shared_ptr<HttpServer> http_server(new HttpServer(8081));
http_server->Start();
return 0;
}
readPool &operator=(const ThreadPool &) = delete; };
ThreadPool* ThreadPool::_inst = nullptr;
到此整个服务器就写的差不多了。但是服务器一般都是需要24*7h提供服务的,也就是说这个进程需要一直运行, 他是一个前台进程,会占用终端,这个进程就会占用用户当前的终端或会话,使得用户无法在终端中执行其他命令,直到前台进程执行完毕或被暂停。
当前终端退出后,此进程就会退出。这很显然不合适。使用守护进程解决这个问题。
### 守护进程
守护进程(Daemon Process)是在后台运行的一种特殊类型的进程,通常在操作系统启动时自动启动,并且在操作系统关闭时自动终止。它们通常在后台运行,不会与用户交互,并且通常用于执行系统任务或提供服务。
简单的来说,前台进程就是和终端关联的进程,守护进程就是不和终端关联的进程。终端退出,进程也退出的就是前台进程,不退出的就是后台进程。
守护进程就是让当前进程和bash不在属于一个会话组,让自己独立成为一个会话组。使用`setsid`系统调用就可以创建一个新的会话组,但是调用`setsid`有一个前提就是调用`setsid`的进程不能是守护组的组长。在命令行中 用管道创建一批进程,这一批进程可以成为一个进程组,组长般是第一个进程。防止调用`setsid`是组长进程,使用fork创建子进程,让父进程退出,子进程调用`setsid`,调用setsid后,调用进程会成为新会话的领头进程,并且不再和终端关联。
成为守护进程后,也就不能向终端打印信息了,Linux下有一个文件黑洞,在`/dev/null`目录下。将进程的所有输出都重定向到该文件下即可。
```cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstdlib>
// 守护进程
void Daemon()
{
// 1.忽略SIGPIPE 和SIGCHLD信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 让父进程退出(守护进程不能是当前进程组的组长)
if (fork() > 0)
{
exit(0);
}
//3. 调用setsid让子进程成为守护进程
setsid();
//4. 守护进程不能在向终端中打印,Linux下dev/null文件是一个文件黑洞,所有文件都可以向这里显示,不影响终端正常运行
int devnull = open("/dev/null", O_RDONLY | O_WRONLY);
if (devnull > 0)
{
dup2(0, devnull);
dup2(1, devnull);
dup2(2, devnull);
close(devnull);
}
}
使用守护进程。
在main
函数中调用Daemon函数即可。一般main函数启动的时候就将当前进程转换为守护进程即可。
#include "Http_Server.hpp"
#include "Daemon.hpp"
#include <memory>
int main()
{
Daemon();//使用守护进程
std::shared_ptr<HttpServer> http_server(new HttpServer(8081));
http_server->Start();
return 0;
}