# HttpServer **Repository Path**: linkylo/HttpServer ## Basic Information - **Project Name**: HttpServer - **Description**: 自主实现HTTP服务器 - **Primary Language**: C++ - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 5 - **Forks**: 4 - **Created**: 2023-03-07 - **Last Updated**: 2025-02-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README [TOC] # 项目介绍 该项目是一个基于HTTP和TCP协议自主实现的HttpServer,用于实现服务器对钢琴教学机械手上位机软件(客户端)发送过来的GET和POST请求的接收、解析、处理,并返回处理结果给到钢琴教学机械手上位机软件(客户端)。该项目主要背景知识涉及C++、C#、网络分层协议栈、HTTP协议、网络套接字编程、CGI技术、单例模式、多线程编程、线程池等。 ![image-20230517004152966](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230517004152966.png) **项目源码**:[Click](https://gitee.com/linkylo/HttpServer) ![image-20230311111150579](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311111150579.png) ## CGI技术 > CGI技术可能大家比较陌生,单拎出来提下。 ### 概念 CGI(通用网关接口,Common Gateway Interface)是一种用于在Web服务器上执行程序并生成动态Web内容的技术。CGI程序可以是任何可执行程序,通常是脚本语言,例如Perl或Python。 **CGI技术允许Web服务器通过将Web请求传递给CGI程序来执行任意可执行文件**。CGI程序接收HTTP请求,并生成HTTP响应以返回给Web服务器,最终返回给Web浏览器。这使得Web服务器能够动态地生成网页内容,与静态HTML文件不同。CGI程序可以处理表单数据、数据库查询和其他任务,从而实现动态Web内容。一些常见的用途包括创建动态网页、在线购物车、用户注册、论坛、网上投票等。 ### 原理 通过Web服务器将Web请求传递给CGI程序,CGI程序处理请求并生成响应,然后将响应传递回Web服务器,最终返回给客户端浏览器。这个过程可以概括为: 1. 客户端发送HTTP请求到Web服务器。 2. Web服务器检查请求类型,如果是CGI请求,Web服务器将环境变量和请求参数传递给CGI程序,并等待CGI程序的响应。 3. CGI程序接收请求参数,并执行相应的操作,例如读取数据库或处理表单数据等。 4. CGI程序生成HTTP响应,将响应返回给Web服务器。 5. Web服务器将响应返回给客户端浏览器。 ![image-20230311112450625](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311112450625.png) 在这个过程中,Web服务器和CGI程序之间通过标准输入和标准输出(建立管道并重定向到标准输入输出)进行通信。Web服务器将请求参数通过环境变量传递给CGI程序,CGI程序将生成的响应通过标准输出返回给Web服务器。此外,CGI程序还可以通过其他方式与Web服务器进行通信,例如通过命令行参数或文件进行交互。 # 设计框架 ## 日志文件 用于记录下服务器运行过程中产生的一些事件。日志格式如下: ![image-20230311113235121](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311113235121.png) 日志级别说明: - **INFO:** 表示正常的日志输出,一切按预期运行。 - **WARNING:** 表示警告,该事件不影响服务器运行,但存在风险。 - **ERROR:** 表示发生了某种错误,但该事件不影响服务器继续运行。 - **FATAL:** 表示发生了致命的错误,该事件将导致服务器停止运行。 文件名称和行数可以通过C语言中的预定义符号`__FILE__`和`__LINE__`,分别可以获取当前文件的名称和当前的行数。 ```c #define INFO 1 #define WARNING 2 #define ERROR 3 #define FATAL 4 // #将宏参数level转为字符串格式 #define LOG(level, message) Log(#level, message, __FILE__, __LINE__) ``` ## TCPServer 思路是:创建一个TCP服务器,并通过初始化、绑定和监听等步骤实现对外服务。 具体实现中,单例模式通过一个名为`GetInstance`的静态方法实现,该方法首先使用pthread_mutex_t保证线程安全,然后使用静态变量 _svr指向单例对象,如果 _svr为空,则创建一个新的TcpServer对象并初始化,最后返回 _svr指针。由于 _svr是static类型的,因此可以确保整个程序中只有一个TcpServer实例。 `Socket`方法用于创建一个监听套接字,`Bind`方法用于将端口号与IP地址绑定,`Listen`方法用于将监听套接字置于监听状态,等待客户端连接。`Sock`方法用于返回监听套接字的文件描述符。 ## 任务类 描述任务对象,并调用回调函数。 ```c // 任务类 class Task{ private: int _sock; // 通信套接字 CallBack _handler; // 回调函数 public: Task() {} ~Task() {} Task(int sock) // accept建立连接成功产生的通信套接字sock :_sock(sock) {} // 执行任务 void ProcessOn() { _handler(_sock); //_handler对象的运算符()已经重装,直接调用重载的() } }; ``` ## 初始化与启动HttpServer 这部分包含一个初始化服务器的方法InitServer()和一个启动服务器的方法Loop()。其中InitServer()函数注册了一个信号处理函数,忽略SIGPIPE信号(避免写入崩溃)。而Loop()函数则通过调用TcpServer类的单例对象获取监听套接字,然后通过accept()函数等待客户端连接,每当有客户端连接进来,就创建一个线程来处理该客户端的请求,并把任务放入线程池中。这里的Task是一个简单的封装,它包含一个处理客户端请求的成员函数,该成员函数读取客户端请求,解析请求,然后调用CGI程序来执行请求,最后将响应发送给客户端。 ## HTTP请求结构 将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中。 ## HTTP响应结构 类似的,HTTP响应也封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。 ## 线程回调 该回调函数实际上是一个函数对象,其重载了圆括号运算符“()”。当该函数对象被调用时,会传入一个int类型的套接字描述符作为参数,代表与客户端建立的连接套接字。该函数对象内部通过创建一个EndPoint对象来处理该客户端发来的HTTP请求,包括读取请求、处理请求、构建响应和发送响应。处理完毕后,该连接套接字将被关闭,EndPoint对象也会被释放。 ## EndPoint类 ![image-20230311134619091](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311134619091.png) ### EndPoint主体框架 EndPoint类中包含三个成员变量: - sock:表示与客户端进行通信的套接字。 - http_request:表示客户端发来的HTTP请求。 - http_response:表示将会发送给客户端的HTTP响应。 - _stop:是否异常停止本次处理 EndPoint类中主要包含四个成员函数: - RecvHttpRequest:读取客户端发来的HTTP请求。 - HandlerHttpRequest:处理客户端发来的HTTP请求。 - BuildHttpResponse:构建将要发送给客户端的HTTP响应。 - SendHttpResponse:发送HTTP响应给客户端。 ```c //服务端EndPoint class EndPoint{ private: int _sock; //通信的套接字 HttpRequest _http_request; //HTTP请求 HttpResponse _http_response; //HTTP响应 bool _stop; //是否停止本次处理 public: EndPoint(int sock) :_sock(sock) {} //读取请求 void RecvHttpRequest(); //处理请求 void HandlerHttpRequest(); //构建响应 void BuildHttpResponse(); //发送响应 void SendHttpResponse(); ~EndPoint() {} }; ``` ### 读取HTTP请求 读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。 ### 处理HTTP请求 首先判断请求方法是否为GET或POST,如果不是则返回错误信息;然后判断请求是GET还是POST,设置对应的cgi、路径和查询字符串;接着拼接web根目录和请求资源路径,并判断路径是否以/结尾,如果是则拼接index.html;获取请求资源文件的属性信息,并根据属性信息判断是否需要使用CGI模式处理;获取请求资源文件的后缀,进行CGI或非CGI处理。 ![image-20230311134644961](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311134644961.png) #### CGI处理 CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。 ![image-20230311132028564](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311132028564.png) 创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端: - 对于父进程来说,input管道是用来读数据的,因此父进程需要保留input[0]关闭input[1],而output管道是用来写数据的,因此父进程需要保留output[1]关闭output[0]。 - 对于子进程来说,input管道是用来写数据的,因此子进程需要保留input[1]关闭input[0],而output管道是用来读数据的,因此子进程需要保留output[0]关闭output[1]。 此时父子进程之间的通信信道已经建立好了,但为了让替换后的CGI程序从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据,因此在子进程进行进程程序替换之前,还需要对子进程进行重定向。 假设子进程保留的input[1]和output[0]对应的文件描述符分别是3和4,那么子进程对应的文件描述符表的指向大致如下: ![image-20230311132058745](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311132058745.png) 现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道。 ![image-20230311132120757](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311132120757.png) 此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递: - 首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。 - 如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。 - 如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。 此时子进程就可以进行进程程序替换了,而父进程需要做如下工作: - 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。 - 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。 - 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。 #### 非CGI处理 ​ 非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但这种做法还可以优化。  因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。我们完全**可以调用sendfile函数直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送**。 ​ sendfile函数是一个系统调用函数,用于将一个文件描述符指向的文件内容直接发送给另一个文件描述符指向的套接字,从而实现了零拷贝(Zero Copy)技术。这种技术避免了数据在用户态和内核态之间的多次拷贝,从而提高了数据传输效率。 > sendfile函数的使用场景通常是在Web服务器中,用于将静态文件直接发送给客户端浏览器,从而避免了将文件内容复制到用户空间的过程。在Linux系统中,sendfile函数的原型为: ``` #include ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); ``` ​ 其中,out_fd表示目标文件描述符,in_fd表示源文件描述符,offset表示源文件偏移量,count表示要发送的字节数。函数返回值表示成功发送的字节数,如果返回-1则表示出现了错误。 ## 构建HTTP响应 构建 HTTP 响应报文,首先根据响应的状态码构建状态行(包含 HTTP 版本、状态码和状态码描述),然后根据状态码分别构建不同的响应报头和响应正文。如果状态码为 200 OK,则调用 BuildOkResponse() 函数构建成功的响应报头和响应正文;如果状态码为 404 NOT FOUND、400 BAD REQUEST 或 500 INTERNAL SERVER ERROR,则根据不同的状态码构建相应的错误响应报头和响应正文,并调用 HandlerError() 函数处理错误。 ## 发送HTTP响应 发送HTTP响应的步骤如下: - 调用send函数,依次发送状态行、响应报头和空行。 - 发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。 - 如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。 ## 接入线程池 当前多线程版服务器存在的问题: - 每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。 - 如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。 考虑接入线程池简单优化下(其实也可以直接上epoll) - 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。 - 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。 ## 简单测试 默认页面测试: ![image-20230311134104523](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311134104523.png) 带query_string,CGI传参测试: ![image-20230311134047088](https://typora-1310561439.cos.ap-guangzhou.myqcloud.com/image-20230311134047088.png) 以课题中的软件通信为例,对HttpServer进行扩展。 ## 钢琴助手上位机软件Http请求的构建 **因为一般我们用浏览器访问网站,浏览器会自动为我们构建一个HTTP请求报文,而上位机软件的话没有这个报文构建功能,所以要自己补充一个上位机软件构建HTTP请求的过程**: 1. 引用必要的命名空间:首先需要引用`System.Net.Http`命名空间,以便使用HttpClient类进行HTTP通信。 2. 创建HttpClient实例:使用HttpClient类来发送HTTP请求和接收响应。在代码中实例化一个HttpClient对象。 ```c using System; using System.Net.Http; // ... HttpClient httpClient = new HttpClient(); ``` 3. 构建HTTP请求:构建HTTP请求。指定请求的URL、HTTP方法(例如GET、POST等)、请求头、请求体等。例如,如果想要发送一个GET请求,可以使用GetAsync方法: ```c string url = "http://example.com/api/resource"; HttpResponseMessage response = await httpClient.GetAsync(url); ``` 如果需要发送POST请求,并包含请求体数据,可以使用PostAsync方法: ```c string url = "http://example.com/api/resource"; string requestBody = "your request body data"; HttpContent content = new StringContent(requestBody); HttpResponseMessage response = await httpClient.PostAsync(url, content); ``` 4. 处理服务器响应:通过HttpResponseMessage对象可以获取服务器的响应信息,包括响应状态码、响应头、响应体等。可以根据需要处理和解析响应数据。 ```c if (response.IsSuccessStatusCode) { // 处理成功的响应 string responseBody = await response.Content.ReadAsStringAsync(); Console.WriteLine("响应数据:" + responseBody); } else { // 处理错误的响应 Console.WriteLine("请求失败,状态码:" + response.StatusCode); } ``` 5. 关闭和释放资源:完成通信后,确保关闭和释放相关的资源。HttpClient类实现了IDisposable接口,因此可以通过using语句块来自动释放资源。 ```c using (HttpClient httpClient = new HttpClient()) { // 发送和处理HTTP请求的代码 } ``` ## 钢琴助手上位机软件与服务器间的基本身份验证 ### 1.上位机(客户端) 1. 基本身份验证(Basic Authentication):这是一种简单的身份验证方法,其中用户凭据(用户名和密码)通过Base64编码添加到请求的`Authorization`头中发送到服务器。服务器将验证这些凭据以确定用户的身份。以下是使用HttpClient进行基本身份验证的示例: ```c string username = "your_username"; string password = "your_password"; string url = "http://example.com/api/resource"; // 将用户名和密码编码为Base64字符串 string encodedCredentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); // 设置HTTP请求的Authorization头 httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", encodedCredentials); // 发送请求 HttpResponseMessage response = await httpClient.GetAsync(url); ``` > `username`和`password`是实际的用户名和密码,而`url`应该是要请求的API资源的URL。 在编码用户名和密码时,您可以使用`Encoding.UTF8.GetBytes`方法,这将返回一个`byte[]`数组。然后,您可以使用`Convert.ToBase64String`方法将这个字节数组转换为Base64字符串。此字符串可以作为`Authorization`头的值发送到服务器。 当服务器收到请求时,它将会解码`Authorization`头并验证凭据是否匹配。如果凭据有效,则服务器将返回请求的资源。如果凭据无效,则服务器将返回401(未授权)状态码。 ### 2.服务器端 在服务器端使用C++进行基本身份验证,可以按照以下步骤进行处理: 1. 接收HTTP请求:使用C++编写服务器端代码来接收来自客户端的HTTP请求。 2. **解析请求头**:从接收到的HTTP请求中解析出`Authorization`头的值。您可以使用相应的HTTP请求解析库或手动解析请求头来获取基本身份验证信息。 3. **解码凭据**:使用Base64解码从请求头中提取的凭据。您可以使用Base64解码库或实现自己的Base64解码逻辑。 4. **验证凭据**:将解码后的凭据与存储在服务器端的用户名和密码进行比较。可以将用户名和密码存储在数据库中,或者在代码中硬编码。 5. 发送响应:根据验证结果,发送相应的HTTP响应给客户端。如果凭据有效,可以返回所请求资源的数据;如果凭据无效,可以返回401(未授权)状态码。 基本代码思路如下: ```c #include #include #include #include #include #include // Base64解码函数 std::string base64_decode(const std::string& encodedStr) { // 实现Base64解码逻辑 // ... } int main() { // 接收HTTP请求并解析请求头 std::string authorizationHeader = "Basic base64_encoded_credentials"; // 假设这是从请求中获取的Authorization头的值 // 解码凭据 std::string base64EncodedCredentials = authorizationHeader.substr(6); // 去除"Basic "前缀 std::string decodedCredentials = base64_decode(base64EncodedCredentials); // 提取用户名和密码 std::istringstream iss(decodedCredentials); std::string username, password; std::getline(iss, username, ':'); std::getline(iss, password); // 验证凭据 std::string storedUsername = "valid_username"; // 假设这是存储在服务器上的用户名 std::string storedPassword = "valid_password"; // 假设这是存储在服务器上的密码 if (username == storedUsername && password == storedPassword) { // 凭据有效,返回所请求资源的数据 std::cout << "HTTP/1.1 200 OK\r\n\r\n"; std::cout << "Authorized access to the requested resource."; } else { // 凭据无效,返回401状态码 std::cout << "HTTP/1.1 401 Unauthorized\r\n\r\n"; std::cout << "Invalid credentials."; } return 0; } ``` # 项目其他可改进点 当前项目的重点在于HTTP服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建。还可以进行不少扩展,比如: - 当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。 - 当前项目虽然在后端接入了线程池,但是效果有限,可以将线程池换成epoll版本,让服务器的IO变得更高效。 - 上位机软件部分的账号验证目前是Base-64编码压缩后明文传输,安全性不足,后续可以通过”令牌“等方式进行加密。