HttpServer
HTTPServer项目是一个基于C++编写的简单的HTTP服务器实现,用于处理客户端的HTTP请求并提供相应的服务。该项目使用了Socket编程来实现服务器与客户端之间的通信,通过监听指定的端口并接受客户端连接,然后解析HTTP请求并生成对应的HTTP响应。
主要特点和功能包括:
- 基于线程池处理请求: 使用线程池技术,服务器在接收到客户端连接后,将客户端的请求任务提交到线程池中,由线程池中的线程来处理请求,这样可以有效地管理线程资源,避免频繁创建和销毁线程,提高服务器的性能和稳定性。
- HTTP协议解析: 实现了基本的HTTP协议解析功能,能够解析客户端发送的HTTP请求,包括请求行、请求头部、请求正文等部分,并提取出其中的请求方法、URL、请求参数等信息。
- 静态文件服务: 支持对静态文件的访问,可以根据客户端请求的URL路径,读取服务器本地的静态文件,并将文件内容作为HTTP响应返回给客户端。
- CGI机制: 通过CGI机制,HTTP服务器可以实现更复杂的功能,例如处理表单提交、与数据库交互、生成动态页面等。当CGI脚本接收到请求时,它会解析请求参数、执行必要的操作,并生成HTTP响应返回给客户端。
- 异常处理机制: 实现了简单的异常处理机制,对异常情况进行了处理,包括客户端连接异常、HTTP请求解析错误等,保证服务器的稳定性和可靠性。
该项目将会把HTTP中最核心的模块抽取出来,采用CS模型实现一个小型的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
设计成为单例模式,保证此类只能实例化一个对象。
关于单例模式。参考博客:单例模式-优快云博客
#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);