【项目】Http服务器

HttpServer

HTTPServer项目是一个基于C++编写的简单的HTTP服务器实现,用于处理客户端的HTTP请求并提供相应的服务。该项目使用了Socket编程来实现服务器与客户端之间的通信,通过监听指定的端口并接受客户端连接,然后解析HTTP请求并生成对应的HTTP响应。

主要特点和功能包括:

  1. 基于线程池处理请求: 使用线程池技术,服务器在接收到客户端连接后,将客户端的请求任务提交到线程池中,由线程池中的线程来处理请求,这样可以有效地管理线程资源,避免频繁创建和销毁线程,提高服务器的性能和稳定性。
  2. HTTP协议解析: 实现了基本的HTTP协议解析功能,能够解析客户端发送的HTTP请求,包括请求行、请求头部、请求正文等部分,并提取出其中的请求方法、URL、请求参数等信息。
  3. 静态文件服务: 支持对静态文件的访问,可以根据客户端请求的URL路径,读取服务器本地的静态文件,并将文件内容作为HTTP响应返回给客户端。
  4. CGI机制: 通过CGI机制,HTTP服务器可以实现更复杂的功能,例如处理表单提交、与数据库交互、生成动态页面等。当CGI脚本接收到请求时,它会解析请求参数、执行必要的操作,并生成HTTP响应返回给客户端。
  5. 异常处理机制: 实现了简单的异常处理机制,对异常情况进行了处理,包括客户端连接异常、HTTP请求解析错误等,保证服务器的稳定性和可靠性。

该项目将会把HTTP中最核心的模块抽取出来,采用CS模型实现一个小型的HTTP服务器,目的在于理解HTTP协议的处理过程。

开发环境:Linux-Centos7 + vscode。

技术栈:

  • 网络编程 (socket流式套接字,http协议)
  • 多线程技术
  • cgi技术
  • 线程池
  • 重定向

从零实现一个http服务器,先要了解网络协议栈和http协议相关知识。

网络协议栈

image.png

  • 应用层:应用层负责处理特定的网络应用程序和用户数据的交互,提供了一些常见的网络服务,例如HTTP、FTP、SMTP等。在应用层中,数据被转换成特定的应用协议格式,以便应用程序之间的通信。
  • 传输层:传输层负责提供端到端的数据传输服务,主要功能是确保数据的可靠传输和流量控制。常见的传输层协议有TCP(传输控制协议)和UDP(用户数据报协议)
  • 网络层:网络层负责处理数据包在网络中的路由和转发,以实现不同网络之间的通信。它定义了IP(Internet Protocol)地址和路由选择等功能。常见的网络层协议包括IP协议、ICMP协议和ARP协议等。
  • 数据链路层:链路层负责实现节点之间的直接通信,处理物理层面的数据传输问题,例如数据的分帧、错误检测和纠正等。常见的链路层协议有以太网协议、Wi-Fi协议和PPP协议等。

在数据传输的过程中,每经过一层网络协议栈都会添加对应的报头信息。经过层层封装通过网络传输到另一个主机,另一个主机自底向上交付时,又会层层解包最终将数据交付到对端。他们之间的通信是双向的。

此项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上HTTP报头再发送给客户端。

HTTP

HTTP(超文本传输协议)是一种用于传输超文本和其他网络资源的应用层协议,是互联网上数据传输的基础。

特点如下:

  1. 无连接性:HTTP协议是一种无连接的协议,每次请求都是独立的,服务器在处理完客户端的请求并发送响应后立即断开连接。
  2. 无状态性:HTTP协议是一种无状态的协议,即服务器不会记住之前的通信状态。每个请求都是独立的,服务器不会保存客户端的状态信息。
  3. 简单快速:HTTP协议使用简单,容易实现,且传输速度快。HTTP请求和响应的格式简单明了,只需要几个标识符和字段就能完成通信。
  4. 灵活:HTTP协议支持多种数据格式,例如文本、图片、音频、视频等,同时也支持多种传输编码和压缩方式,以便在不同的网络环境下实现更高效的数据传输。
  5. 可扩展性:HTTP协议采用了头部字段(Header)来传输元数据信息,使得协议具有较好的扩展性。可以通过添加新的头部字段来支持新的功能或者扩展现有功能。

HTTP协议本身不具备保存之前发送的请求或者响应的功能(无状态特点),就是每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。但是,速度方面的提升带来了用户体验方面的下降,比如保持用户的登陆状态。不保存之前发送请求和响应的功能,就会带来这一问题。比如当使用浏览器登录b站观看视频时,每次退出浏览器都要重新登录b站,体验很不好。解决这个问题,引入了Cookie技术,通过在请求和响应报文中写入Cookie信息来控制客户端的状态,同时为了保护用户数据的安全,又引入了Session技术,因此现在主流的HTTP服务器都是通过Cookie+Session的方式来控制客户端的状态的。

URL

URL(统一资源定位符)是一种统一资源标识符(URI)的特定类型,用于唯一地标识和定位互联网上的资源。URL提供了一种标准化的方式来指定互联网资源的位置,并且可以通过各种协议(如HTTP、HTTPS、FTP等)来访问这些资源。

一个标准的URL通常包含以下几个部分:

  1. 协议(Scheme) :指定访问资源所使用的协议,例如HTTP、HTTPS、FTP等。协议名后面跟着一个冒号和两个斜杠(例如http://、https://)。
  2. 主机地址(Host) :指定资源所在的主机的域名或IP地址。主机地址通常紧跟在协议之后,由一个或多个部分组成。
  3. 端口号(Port) :可选部分,指定用于访问资源的端口号。如果未指定,默认端口号会随着协议而变化(例如HTTP协议的默认端口号是80)。
  4. 路径(Path) :指定资源在服务器上的路径或位置。路径可以是一个文件名、文件夹名,或者更多的子路径。
  5. 查询参数(Query Parameters) :可选部分,用于向服务器传递参数,参数之间使用“&”符号分隔。

例如,下面是一个标准的浏览器URL示例:

https://www.example.com/path/to/resource?param1=value1&param2=value2
  • 协议是HTTPS。
  • 主机地址是www.example.com
  • 路径是/path/to/resource。
  • 查询参数包括param1和param2,它们分别的值是value1和value2

URL是互联网上资源的标准表示形式,它使得用户和应用程序能够方便地定位和访问网络上的各种资源。

HTTP请求与响应

HTTP请求协议格式:

image.png
HTTP协议请求由请求行、请求头部、空行和请求体组成。以下是HTTP请求的基本格式:

  1. 请求行:请求行包含了请求的方法、资源路径和协议版本,它们之间使用空格分隔。格式为:

    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
    
  2. 请求报头:请求头部包含了客户端向服务器传递的附加信息,每个头部字段都由字段名和字段值组成,中间用冒号分隔。不同的头部字段用换行符分隔。

    例如:

    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
    
  3. 空行:空行用于分隔请求头部和请求体,通常为空行的存在表示请求头部的结束。

  4. 请求正文:对于POST请求等包含请求体的请求方法,请求体包含了客户端向服务器提交的数据。请求体的格式和内容根据具体的应用场景和需求而定。

HTTP响应协议格式:

image.png
HTTP协议响应由状态行、响应头部、空行和响应体组成。以下是HTTP响应的基本格式:

  1. 响应行:状态行包含了响应的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
    
  2. 响应报头:响应头部包含了服务器返回的附加信息,每个头部字段都由字段名和字段值组成,中间用冒号分隔。不同的头部字段用换行符分隔。

    例如:

    Content-Type: text/html; charset=utf-8
    Content-Length: 1234
    
  3. 空行:空行用于分隔响应头部和响应体,通常为空行的存在表示响应头部的结束。

  4. 响应正文:响应体包含了服务器向客户端返回的数据。响应体的格式和内容根据具体的应用场景和需求而定。

一张图说明HTTP整个请求和响应的过程

image.png

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状态码分为五个大类,具体如下:

  1. 1xx:信息性状态码
    • 1xx状态码表示服务器已经接收到请求,但是还需要进一步的处理或者等待客户端发送额外的信息。例如:
    • 100 Continue:指示客户端可以继续发送请求体(在请求头包含 Expect: 100-continue 时使用)。
    • 101 Switching Protocols:服务器已经切换到不同的协议,如HTTP/1.1到WebSocket。
  2. 2xx:成功状态码
    • 2xx状态码表示服务器成功处理了客户端的请求。例如:
    • 200 OK:请求成功,服务器返回请求的内容。
    • 201 Created:请求已经成功创建了新的资源。
    • 204 No Content:服务器成功处理了请求,但不需要返回任何内容。
  3. 3xx:重定向状态码
    • 3xx状态码表示客户端需要采取进一步的操作才能完成请求。例如:
    • 301 Moved Permanently:请求的资源已被永久移动到新的URL。
    • 302 Found:请求的资源已被临时移动到新的URL。
    • 307 Temporary Redirect:请求的资源临时重定向到新的URL,客户端应该保持原始请求方法。
  4. 4xx:客户端错误状态码
    • 4xx状态码表示客户端的请求包含错误或者无法完成。例如:
    • 400 Bad Request:客户端发送的请求有语法错误。
    • 404 Not Found:请求的资源不存在。
  5. 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);
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

C++下等马

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值