• C++搭建HTTP服务器(详细版)

C++搭建HTTP服务器(详细版)

2025-05-02 13:57:32 0 阅读

一. 首先区分Http、Https是什么


http  超文本传输协议  Hypertext Transfer Protocol
        协议:  主要是用在网页传输上,网站访问上使用
        传输的数据是明文的,可以被截获,查看里面的内容--》不够安全

        经常用于 web架构:  c/s   client --》server
        http端口号 80

https 是http协议的加密版本  Hypertext Transfer Protocol Secure
        数据传输是经过加密的,比较安全
        https端口是 443
        是以安全为目标的 HTTP 通道,在HTTP的基础上通过传输加密和身份认证保证了

        传输过程的安全性,在http基础上有证书: 也就是拥有 ssh 服务: 有公钥和私钥

二.项目介绍


        此项目实现的是一个HTTP服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端。

  HTTP在网络应用层中的地位是不可撼动的,无论是移动端还是PC端浏览器,HTTP无疑是打开互联网应用窗口的重要协议。

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

  该项目主要涉及C/C++、HTTP协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技术。

网络协议栈介绍


协议分层
网络协议栈的分层情况如下:

 网络协议栈中各层的功能如下:

  • 应用层:根据特定的通信目的,对数据进行分析处理,以达到某种业务性的目的。
  • 传输层:处理传输时遇到的问题,主要是保证数据传输的可靠性。
  • 网络层:完成数据的转发,解决数据去哪里的问题。
  • 链路层:负责数据真正的发生过程。

数据的封装与分用

数据封装与分用的过程如下:

也就是说,发送端在发生数据前,该数据需要先自顶向下贯穿网络协议栈完成数据的封装,在这个过程中,每一层协议都会为该数据添加上对应的报头信息。接收端在收到数据后,该数据需要先自底向上贯穿网络协议栈完成数据的解包和分用,在这个过程中,每一层协议都会将对应的报头信息提取出来。

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

  需要注意的是,该项目中我们所处的位置是应用层,因此我们读取的HTTP请求实际是从传输层读取上来的,而我们发送的HTTP响应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成,这里直接说“接收到客户端的HTTP请求”以及“发送HTTP响应给客户端”,只是为了方便大家理解,此外,同层协议之间本身也是可以理解成是在直接通信的。


HTTP的特点


HTTP的五大特点如下:

1.客户端服务器模式(CS,BS): 在一条通信线路上必定有一端是客户端,另一端是服务器端,请求从客户端发出,服务器响应请求并返回。
2.简单快速: 客户端向服务器请求服务时,只需传送请求方法和请求资源路径,不需要发送额外过多的数据,并且由于HTTP协议结构较为简单,使得HTTP服务器的程序规模小,因此通信速度很快。
3.灵活: HTTP协议对数据对象没有要求,允许传输任意类型的数据对象,对于正在传输的数据类型,HTTP协议将通过报头中的Content-Type属性加以标记。
4.无连接: 每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。HTTP协议采用这种方式可以大大节省传输时间,提高传输效率。
5.无状态: HTTP协议自身不对请求和响应之间的通信状态进行保存,每个请求都是独立的,这是为了让HTTP能更快地处理大量事务,确保协议的可伸缩性而特意设计的。

说明一下:

随着HTTP的普及,文档中包含大量图片的情况多了起来,每次请求都要断开连接,无疑增加了通信量的开销,因此HTTP1.1支持了长连接Keey-Alive,就是任意一端只要没有明确提出断开连接,则保持连接状态。(当前项目实现的是1.0版本的HTTP服务器,因此不涉及长连接)
HTTP无状态的特点无疑可以减少服务器内存资源的消耗,但是问题也是显而易见的。比如某个网站需要登录后才能访问,由于无状态的特点,那么每次跳转页面的时候都需要重新登录。为了解决无状态的问题,于是引入了Cookie技术,通过在请求和响应报文中写入Cookie信息来控制客户端的状态,同时为了保护用户数据的安全,又引入了Session技术,因此现在主流的HTTP服务器都是通过Cookie+Session的方式来控制客户端的状态的。

URL格式

URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。

一个URL大致由如下几部分构成:

其中:

1.http://表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。
2.user:pass表示的是登录认证信息,包括登录用户的用户名和密码。(可省略)
3.www.example.jp表示的是服务器地址,通常以域名的形式表示。
4.80表示的是服务器的端口号。(可省略)
5./dir/index.html表示的是要访问的资源所在的路径(/表示的是web根目录)。
6.uid=1表示的是请求时通过URL传递的参数,这些参数以键值对的形式通过&符号分隔开。(可省略)
7.ch1表示的是片段标识符,是对资源的部分补充。(可省略)

注意:

如果访问服务器时没有指定要访问的资源路径,那么浏览器会自动帮我们添加/,但此时仍然没有指明要访问web根目录下的哪一个资源文件,这时默认访问的是目标服务的首页。
大部分URL中的端口号都是省略的,因为常见协议对应的端口号都是固定的,比如HTTP、HTTPS和SSH对应的端口号分别是80、443和22,在使用这些常见协议时不必指明协议对应的端口号,浏览器会自动帮我们进行填充。

URI、URL、URN
URI、URL、URN的定义如下:

URI(Uniform Resource Indentifier)统一资源标识符:用来唯一标识资源。
URL(Uniform Resource Locator)统一资源定位符:用来定位唯一的资源。
URN(Uniform Resource Name)统一资源名称:通过名字来标识资源,比如mailto:java-net@java.sun.com。
URI、URL、URN三者的关系:

  URL是URI的一种,URL不仅能唯一标识资源,还定义了该如何访问或定位该资源,URN也是URI的一种,URN通过名字来标识资源,因此URL和URN都是URI的子集。

URI、URL、URN三者的关系如下:

HTTP的协议格式

HTTP请求协议格式

HTTP请求由以下四部分组成:

请求行:[请求方法] + [URI] + [HTTP版本]。
请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
空行:遇到空行表示请求报头结束。
请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。

HTTP响应协议格式

 HTTP响应由以下四部分组成:

状态行:[HTTP版本] + [状态码] + [状态码描述]。
响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
空行:遇到空行表示响应报头结束。
响应正文:响应正文允许为空字符串,如果响应正文存在,则在响应报头中会有一个Content-Length属性来标识响应正文的长度。

HTTP的请求方法

HTTP常见的请求方法如下:

GET方法和POST方法

  HTTP的请求方法中最常用的就是GET方法和POST方法,其中GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器,但实际GET方法也可以用来上传数据,比如百度搜索框中的数据就是使用GET方法提交的。

  GET方法和POST方法都可以带参,其中GET方法通过URL传参,POST方法通过请求正文传参。由于URL的长度是有限制的,因此GET方法携带的参数不能太长,而POST方法通过请求正文传参,一般参数长度没有限制。

HTTP的状态码

  HTTP状态码是用来表示服务器HTTP响应状态的3位数字代码,通过状态码可以知道服务器端是否正确的处理了请求,以及请求处理错误的原因。

HTTP的状态码如下:


HTTP常见的Header如下:

Content-Type:数据类型(text/html等)。
Content-Length:正文的长度。
Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
User-Agent:声明用户的操作系统和浏览器的版本信息。
Referer:当前页面是哪个页面跳转过来的。
Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
Cookie:用户在客户端存储少量信息,通常用于实现会话(session)的功能。

CGI机制介绍


CGI机制的概念
CGI(Common Gateway Interface,通用网关接口)是一种重要的互联网技术,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。CGI描述了服务器和请求处理程序之间传输数据的一种标准。

实际我们在进行网络请求时,无非就两种情况:

浏览器想从服务器上拿下来某种资源,比如打开网页、下载等。
浏览器想将自己的数据上传至服务器,比如上传视频、登录、注册等。

通常从服务器上获取资源对应的请求方法就是GET方法,而将数据上传至服务器对应的请求方法就是POST方法,但实际GET方法有时也会用于上传数据,只不过POST方法是通过请求正文传参的,而GET方法是通过URL传参的。

  而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让HTTP或相关程序对该数据进行处理,比如用户提交的是搜索关键字,那么服务器就需要在后端进行搜索,然后将搜索结果返回给浏览器,再由浏览器对HTML文件进行渲染刷新展示给用户。

  但实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,这些CGI程序可以用任何程序设计语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器。

   其中HTTP获取到数据后,如何调用目标CGI程序、如何传递数据给CGI程序、如何拿到CGI程序的处理结果,这些都属于CGI机制的通信细节,而本项目就是要实现一个HTTP服务器,因此CGI的所有交互细节都需要由我们来完成。

何时需要使用CGI模式

  只要用户请求服务器时上传了数据,那么服务器就需要使用CGI模式对用户上传的数据进行处理,而如果用户只是单纯的想请求服务器上的某个资源文件(比如静态页面)则不需要使用CGI模式,此时直接将用户请求的资源文件返回给用户即可。

  此外,如果用户请求的是服务器上的一个可执行程序,说明用户想让服务器运行这个可执行程序,此时也需要使用CGI模式。

CGI机制的实现步骤


一、创建子进程进行程序替换

  服务器获取到新连接后一般会创建一个新线程为其提供服务,而要执行CGI程序一定需要调用exec系列函数进行进程程序替换,但服务器创建的新线程与服务器进程使用的是同一个进程地址空间,如果直接让新线程调用exec系列函数进行进程程序替换,此时服务器进程的代码和数据就会直接被替换掉,相当于HTTP服务器在执行一次CGI程序后就直接退出了,这肯定是不合理的。因此新线程需要先调用fork函数创建子进程,然后让子进程调用exec系列函数进行进程程序替换。

二、完成管道通信信道的建立

  调用CGI程序的目的是为了让其进行数据处理,因此我们需要通过某种方式将数据交给CGI程序,并且还要能够获取到CGI程序处理数据后的结果,也就是需要进行进程间通信。因为这里的服务器进程和CGI进程是父子进程,因此优先选择使用匿名管道

  由于父进程不仅需要将数据交给子进程,还需要从子进程那里获取数据处理的结果,而管道是半双工通信的,为了实现双向通信于是需要借助两个匿名管道,因此在创建调用fork子进程之前需要先创建两个匿名管道,在创建子进程后还需要父子进程分别关闭两个管道对应的读写端。

三、完成重定向相关的设置

  创建用于父子进程间通信的两个匿名管道时,父子进程都是各自用两个变量来记录管道对应读写端的文件描述符的,但是对于子进程来说,当子进程调用exec系列函数进行程序替换后,子进程的代码和数据就被替换成了目标CGI程序的代码和数据,这也就意味着被替换后的CGI程序无法得知管道对应的读写端,这样父子进程之间也就无法进行通信了。

  需要注意的是,进程程序替换只替换对应进程的代码和数据,而对于进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的。因此子进程进行进程程序替换后,底层创建的两个匿名管道仍然存在,只不过被替换后的CGI程序不知道这两个管道对应的文件描述符罢了。

  这时我们可以做一个约定:被替换后的CGI程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据。这样一来,所有的CGI程序都不需要得知管道对应的文件描述符了,当需要读取数据时直接从标准输入中进行读取,而数据处理的结果就直接写入标准输出就行了。

  当然,这个约定并不是你说有就有的,要实现这个约定需要在子进程被替换之前进行重定向,将0号文件描述符重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端。

以下是执行CGI程序时的一般步骤:

  1. 新线程的创建:服务器接收到HTTP请求后,创建一个新的线程来处理这个请求。

  2. 线程执行CGI:这个新线程负责调用CGI程序。但是,由于CGI程序可能需要执行文件上传、数据库操作或其他需要独立地址空间的操作,直接在该线程中执行CGI程序可能会带来安全和稳定性风险。

  3. 子进程的创建:为了避免这些问题,新线程通常会使用 fork() 系统调用来创建一个子进程。这个子进程拥有父进程(即服务器进程)的地址空间的副本。

  4. 进程地址空间替换:在子进程中,线程可以安全地调用 exec() 系列函数(如 execl(), execv(), execp() 等)。exec() 系列函数会替换子进程的整个地址空间,包括代码段、数据段和堆栈,加载并执行新的CGI程序。

  5. CGI程序执行:此时,CGI程序在子进程中运行,与服务器进程(父进程)隔离。这样,即使CGI程序出现问题,也不会影响到服务器的正常运行。

  6. 结果返回:CGI程序执行完成后,会将结果(如HTML页面、图片等)发送回服务器进程,然后退出。服务器进程再将这些结果发送给客户端。

  7. 资源清理:服务器进程负责清理子进程使用的资源,并等待其他线程或子进程完成工作。

以下是进程地址空间替换的一般步骤:

  1. 调用 exec() 函数: 使用 exec() 系列函数之一来替换当前进程的地址空间。这些函数包括 execl(), execv(), execle(), execve(), execlp()execvp() 等变体。

  2. 参数传递exec() 函数需要至少一个参数,即新程序的路径。其他参数可以传递给新程序的 argv(参数列表),包括程序的命令行参数。

  3. 加载新程序exec() 函数将加载指定路径的新程序到当前进程的地址空间中。这包括程序的代码段、数据段、堆栈等。

  4. 替换地址空间: 当前进程的代码和数据将被新程序的代码和数据替换。新程序的代码将开始执行,而旧程序的执行将被完全中断。

  5. 开始新程序的执行: 一旦新程序加载完成,它将开始执行其 main() 函数,就像直接从命令行启动一样。

  6. 环境变量: 新程序将继承父进程的环境变量,除非 exec() 调用中指定了新的环境设置。

  7. 文件描述符: 新程序将继承父进程打开的文件描述符,除非 exec() 调用中指定了不继承。

  8. 返回值exec() 函数一旦执行,通常不会返回。如果 exec() 调用成功,它将在新程序的上下文中执行,永远不会返回到调用 exec() 的进程。如果 exec() 调用失败,它将返回 -1,并设置全局变量 errno 以指示错误。

以下是一个简单的 exec() 函数使用示例:

#include 
#include 
#include 
#include 

int main() {
    // 定义要执行的程序名称和参数
    const char* program = "/bin/ls";
    std::vector args = {
        "ls",  // 程序名,对于 execvp 来说,这应该与 program 相同
        "-l",  // 以长列表格式显示文件
        "-a",   // 显示所有文件,包括以点开头的隐藏文件
        nullptr // 参数列表的结束标志
    };

    // 使用 execvp() 执行程序
    execvp(program, const_cast(args.data()));

    // 如果 execvp() 失败,将打印错误信息并返回
    std::cerr << "execvp failed: " << strerror(errno) << std::endl;
    return 1;
}

在这个示例中:

  • 我们定义了要执行的程序 program 为 "/bin/ls",这表示我们要执行的是 ls 命令。
  • args 是一个包含程序参数的 std::vector。它包括程序名称(对于 execvp 来说,这应该与 program 相同)、"-l" 和 "-a" 参数,以及一个 nullptr 来标记参数列表的结束。
  • execvp() 函数尝试执行指定的程序,使用 args.data() 提供的参数数组。
  • 如果 execvp() 成功执行,当前进程的地址空间将被新程序替换,并且不会执行后面的代码。
  • 如果 execvp() 失败,将打印错误信息。错误信息使用 strerror(errno) 获取,其中 errno 是一个全局变量,它在 execvp() 调用失败时被设置。

父子进程交付数据

  这时父子进程已经能够通过两个匿名管道进行通信了,接下来就应该讨论父进程如何将数据交给CGI程序,以及CGI程序如何将数据处理结果交给父进程了。

父进程将数据交给CGI程序:

如果请求方法为GET方法,那么用户是通过URL传递参数的,此时可以在子进程进行进程程序替换之前,通过putenv函数将参数导入环境变量,由于环境变量也不受进程程序替换的影响,因此被替换后的CGI程序就可以通过getenv函数来获取对应的参数。
如果请求方法为POST方法,那么用户是通过请求正文传参的,此时父进程直接将请求正文中的数据写入管道传递给CGI程序即可,但是为了让CGI程序知道应该从管道读取多少个参数,父进程还需要通过putenv函数将请求正文的长度导入环境变量。
说明一下: 请求正文长度、URL传递的参数以及请求方法都比较短,通过写入管道来传递会导致效率降低,因此选择通过导入环境变量的方式来传递。

  也就是说,使用CGI模式时如果请求方法为POST方法,那么CGI程序需要从管道读取父进程传递过来的数据,如果请求方法为GET方法,那么CGI程序需要从环境变量中获取父进程传递过来的数据。

  但被替换后的CGI程序实际并不知道本次HTTP请求所对应的请求方法,因此在子进程在进行进程程序替换之前,还需要通过putenv函数将本次HTTP请求所对应的请求方法也导入环境变量。因此CGI程序启动后,首先需要先通过环境变量得知本次HTTP请求所对应的请求方法,然后再根据请求方法对应从管道或环境变量中获取父进程传递过来的数据。

  CGI程序读取到父进程传递过来的数据后,就可以进行对应的数据处理了,最终将数据处理结果写入到管道中,此时父进程就可以从管道中读取CGI程序的处理结果了。

CGI机制的处理流程如下:

处理HTTP请求的步骤如下:

判断请求方法是GET方法还是POST方法,如果是GET方法带参或POST方法则进行CGI处理,如果是GET方法不带参则进行非CGI处理。
非CGI处理就是直接根据用户请求的资源构建HTTP响应返回给浏览器。
CGI处理就是通过创建子进程进行程序替换的方式来调用CGI程序,通过创建匿名管道、重定向、导入环境变量的方式来与CGI程序进行数据通信,最终根据CGI程序的处理结果构建HTTP响应返回给浏览器。

CGI机制的意义

CGI机制就是让服务器将获取到的数据交给对应的CGI程序进行处理,然后将CGI程序的处理结果返回给客户端,这显然让服务器逻辑和业务逻辑进行了解耦,让服务器和业务程序可以各司其职。
 CGI机制使得浏览器输入的数据最终交给了CGI程序,而CGI程序输出的结果最终交给了浏览器。这也就意味着CGI程序的开发者,可以完全忽略中间服务器的处理逻辑,相当于CGI程序从标准输入就能读取到浏览器输入的内容,CGI程序写入标准输出的数据最终就能输出到浏览器。

日志编写

        服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。

日志格式

本项目中的日志格式如下:

日志说明:

  • 日志级别: 分为四个等级,从低到高依次是INFO、WARNING、ERROR、FATAL。
  • 时间戳: 事件产生的时间。
  • 日志信息: 事件产生的日志信息。
  • 错误文件名称: 事件在哪一个文件产生。
  • 行数: 事件在对应文件的哪一行产生。

日志级别说明:

INFO: 表示正常的日志输出,一切按预期运行。
WARNING: 表示警告,该事件不影响服务器运行,但存在风险。
ERROR: 表示发生了某种错误,但该事件不影响服务器继续运行。
FATAL: 表示发生了致命的错误,该事件将导致服务器停止运行。
日志函数编写

  我们可以针对日志编写一个输出日志的Log函数,该函数的参数就包括日志级别、日志信息、错误文件名称、错误的行数。如下:

void Log(std::string level, std::string message, std::string file_name, int line)
{
    std::cout<<"["<

说明一下: 调用time函数时传入nullptr即可获取当前的时间戳,因此调用Log函数时不必传入时间戳。

文件名称和行数的问题

  通过C语言中的预定义符号__FILE__和__LINE__,分别可以获取当前文件的名称和当前的行数,但最好在调用Log函数时不用调用者显示的传入__FILE__和__LINE__,因为每次调用Log函数时传入的这两个参数都是固定的。

  需要注意的是,不能将__FILE__和__LINE__设置为参数的缺省值,因为这样每次获取到的都是Log函数所在的文件名称和所在的行数。而宏可以在预处理期间将代码插入到目标地点,因此我们可以定义如下宏:

#define LOG(level, message) Log(level, message, __FILE__, __LINE__)
 后续需要打印日志的时候就直接调用LOG,调用时只需要传入日志级别和日志信息,在预处理期间__FILE__和__LINE__就会被插入到目标地点,这时就能获取到日志产生的文件名称和对应的行数了。

日志级别传入问题

  我们后续调用LOG传入日志级别时,肯定希望以INFO、WARNING这样的方式传入,而不是以"INFO"、"WARNING"这样的形式传入,这时我们可以将这四个日志级别定义为宏,然后通过#将宏参数level变成对应的字符串。如下:

#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4
 
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
此时以INFO、WARNING的方式传入LOG的宏参数,就会被转换成对应的字符串传递给Log函数的level参数,后续我们就可以以如下方式输出日志了:

LOG(INFO, "This is a demo"); //LOG使用示例

套接字相关代码编写


       我们可以将套接字相关的代码封装到TcpServer类中,在初始化TcpServer对象时完成套接字的创建、绑定和监听动作,并向外提供一个Sock接口用于获取监听套接字。

此外,可以将TcpServer设置成单例模式:

1.将TcpServer类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
2.提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
3.提供一个全局访问点获取单例对象,在单例对象第一次被获取的时候就创建这个单例对象并进行初始化。

代码如下:

#define BACKLOG 5
 
//TCP服务器
class TcpServer{
    private:
        int _port;              //端口号
        int _listen_sock;       //监听套接字
        static TcpServer* _svr; //指向单例对象的static指针
    private:
        //构造函数私有
        TcpServer(int port)
            :_port(port)
            ,_listen_sock(-1)
        {}
        //将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
        TcpServer(const TcpServer&)=delete;
        TcpServer* operator=(const TcpServer&)=delete;
    public:
        //获取单例对象
        static TcpServer* GetInstance(int port)
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
            if(_svr == nullptr){
                pthread_mutex_lock(&mtx); //加锁
                if(_svr == nullptr){
                    //创建单例TCP服务器对象并初始化
                    _svr = new TcpServer(port);
                    _svr->InitServer();
                }
                pthread_mutex_unlock(&mtx); //解锁
            }
            return _svr; //返回单例对象
        }
        //初始化服务器
        void InitServer()
        {
            Socket(); //创建套接字
            Bind();   //绑定
            Listen(); //监听
            LOG(INFO, "tcp_server init ... success");
        }
        //创建套接字
        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));
            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;
 
            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");
        }
        //获取监听套接字
        int Sock()
        {
            return _listen_sock;
        }
        ~TcpServer()
        {
            if(_listen_sock >= 0){ //关闭监听套接字
                close(_listen_sock);
            }
        }
};
//单例对象指针初始化为nullptr
TcpServer* TcpServer::_svr = nullptr;

 说明一下:

如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显式绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。
在第一次调用GetInstance获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以PTHREAD_MUTEX_INITIALIZER的方式定义的静态的锁是不需要释放的,同时为了保证后续调用GetInstance获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。

HTTP服务器主体逻辑
  我们可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端口号,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。
代码如下:

#define PORT 8081
 
//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        HttpServer(int port)
            :_port(port)
        {}
 
        //启动服务器
        void Loop()
        {
            LOG(INFO, "loop begin");
            TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
            int listen_sock = tsvr->Sock(); //获取监听套接字
            while(true){
                struct sockaddr_in peer;
                memset(&peer, 0, sizeof(peer));
                socklen_t len = sizeof(peer);
                int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
                if(sock < 0){
                    continue; //获取失败,继续获取
                }
 
                //打印客户端相关信息
                std::string client_ip = inet_ntoa(peer.sin_addr);
                int client_port = ntohs(peer.sin_port);
                LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
                
                //创建新线程处理新连接发起的HTTP请求
                int* p = new int(sock);
                pthread_t tid;
                pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
                pthread_detach(tid); //线程分离
            }
        }
        ~HttpServer()
        {}
};

说明一下:

服务器需要将新连接对应的套接字作为参数传递给新线程,为了避免该套接字在新线程读取之前被下一次获取到的套接字覆盖,因此在传递套接字时最好重新new一块空间来存储套接字的值。
新线程创建后可以将新线程分离,分离后主线程继续获取新连接,而新线程则处理新连接发来的HTTP请求,代码中的HandlerRequest函数就是新线程处理新连接时需要执行的回调函数。


主函数逻辑

  运行服务器时要求指定服务器的端口号,我们用这个端口号创建一个HttpServer对象,然后调用Loop函数运行服务器,此时服务器就会不断获取新连接并创建新线程来处理连接。

代码如下:

static void Usage(std::string proc)
{
    std::cout<<"Usage:
	"< svr(new HttpServer(port)); //创建HTTP服务器对象
    svr->Loop(); //启动服务器
    return 0;
}

HTTP请求结构设计
HTTP请求类

  我们可以将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中。

代码如下:

//HTTP请求
class HttpRequest{
    public:
        //HTTP请求内容
        std::string _request_line;                //请求行
        std::vector _request_header; //请求报头
        std::string _blank;                       //空行
        std::string _request_body;                //请求正文
 
        //解析结果
        std::string _method;       //请求方法
        std::string _uri;          //URI
        std::string _version;      //版本号
        std::unordered_map _header_kv; //请求报头中的键值对
        int _content_length;       //正文长度
        std::string _path;         //请求资源的路径
        std::string _query_string; //uri中携带的参数
 
        //CGI相关
        bool _cgi; //是否需要使用CGI模式
    public:
        HttpRequest()
            :_content_length(0) //默认请求正文长度为0
            ,_cgi(false)        //默认不使用CGI模式
        {}
        ~HttpRequest()
        {}
};

HTTP响应结构设计
HTTP响应类

  HTTP响应也可以封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。后续构建响应时就可以定义一个HTTP响应类,构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。

代码如下:

//HTTP响应
class HttpResponse{
    public:
        //HTTP响应内容
        std::string _status_line;                  //状态行
        std::vector _response_header; //响应报头
        std::string _blank;                        //空行
        std::string _response_body;                //响应正文(CGI相关)
 
        //所需数据
        int _status_code;    //状态码
        int _fd;             //响应文件的fd  (非CGI相关)
        int _size;           //响应文件的大小(非CGI相关)
        std::string _suffix; //响应文件的后缀(非CGI相关)
    public:
        HttpResponse()
            :_blank(LINE_END) //设置空行
            ,_status_code(OK) //状态码默认为200
            ,_fd(-1)          //响应文件的fd初始化为-1
            ,_size(0)         //响应文件的大小默认为0
        {}
        ~HttpResponse()
        {}
};

EndPoint类编写
EndPoint结构设计

  EndPoint这个词经常用来描述进程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处理请求的类取名为EndPoint。

EndPoint类中包含三个成员变量:

sock:表示与客户端进行通信的套接字。
http_request:表示客户端发来的HTTP请求。
http_response:表示将会发送给客户端的HTTP响应。
EndPoint类中主要包含四个成员函数:

RecvHttpRequest:读取客户端发来的HTTP请求。
HandlerHttpRequest:处理客户端发来的HTTP请求。
BuildHttpResponse:构建将要发送给客户端的HTTP响应。
SendHttpResponse:发送HTTP响应给客户端。
代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        EndPoint(int sock)
            :_sock(sock)
        {}
        //读取请求
        void RecvHttpRequest();
        //处理请求
        void HandlerHttpRequest();
        //构建响应
        void BuildHttpResponse();
        //发送响应
        void SendHttpResponse();
        ~EndPoint()
        {}
};

设计线程回调

设计线程回调

服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程要做的实际就是定义一个EndPoint对象,然后依次进行读取请求、处理请求、构建响应、发送响应,处理完毕后将与客户端建立的套接字关闭即可。

代码如下:

class CallBack{
    public:
        static void* HandlerRequest(void* arg)
        {
            LOG(INFO, "handler request begin");
            int sock = *(int*)arg;
            
            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest();    //读取请求
            ep->HandlerHttpRequest(); //处理请求
            ep->BuildHttpResponse();  //构建响应
            ep->SendHttpResponse();   //发送响应
 
            close(sock); //关闭与该客户端建立的套接字
            delete ep;
 
            LOG(INFO, "handler request end");
            return nullptr;
        }
};

读取HTTP请求

读取HTTP请求

  读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //读取请求
        void RecvHttpRequest()
        {
            RecvHttpRequestLine();    //读取请求行
            RecvHttpRequestHeader();  //读取请求报头和空行
            ParseHttpRequestLine();   //解析请求行
            ParseHttpRequestHeader(); //解析请求报头
            RecvHttpRequestBody();    //读取请求正文
        }
};

一、读取请求行

  读取请求行很简单,就是从套接字中读取一行内容存储到HTTP请求类中的request_line中即可。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //读取请求行
        void RecvHttpRequestLine()
        {
            auto& line = _http_request._request_line;
            if(Util::ReadLine(_sock, line) > 0){
                line.resize(line.size() - 1); //去掉读取上来的

            }
        }
};

需要注意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数进行读取,因为不同平台下的行分隔符可能是不一样的,可能是 、 或者 。

因此我们这里需要自己写一个ReadLine函数,以确保能够兼容这三种行分隔符。我们可以把这个函数写到一个工具类当中,后续编写的处理字符串的函数也都写到这个类当中。

ReadLine函数的处理逻辑如下:

  • 从指定套接字中读取一个个字符。
  • 如果读取到的字符既不是 也不是 ,则将读取到的字符push到用户提供的缓冲区后继续读取下一个字符。
  • 如果读取到的字符是 ,则说明行分隔符是 ,此时将 push到用户提供的缓冲区后停止读取。
  • 如果读取到的字符是 ,则需要继续窥探下一个字符是否是 ,如果窥探成功则说明行分隔符为 ,此时将未读取的 读取上来后,将 push到用户提供的缓冲区后停止读取;如果窥探失败则说明行分隔符是 ,此时也将 push到用户提供的缓冲区后停止读取。

  也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把 push到了用户提供的缓冲区当中,相当于将这三种行分隔符统一转换成了以 为行分隔符,只不过最终我们把 一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的 ,需要后续自行将其去掉。

代码如下:

//工具类
class Util{
    public:
        //读取一行
        static int ReadLine(int sock, std::string& out)
        {
            char ch = 'X'; //ch只要不初始化为
即可(保证能够进入while循环)
            while(ch != '
'){
                ssize_t size = recv(sock, &ch, 1, 0);
                if(size > 0){
                    if(ch == '
'){
                        //窥探下一个字符是否为

                        recv(sock, &ch, 1, MSG_PEEK);

                        if(ch == '
'){ //下一个字符是

                            //
->

                            recv(sock, &ch, 1, 0); //将这个
读走
                        }
                        else{ //下一个字符不是

                            //
->

                            ch = '
'; //将ch设置为

                        }
                    }
                    //普通字符或

                    out.push_back(ch);
                }
                else if(size == 0){ //对方关闭连接
                    return 0;
                }
                else{ //读取失败
                    return -1;
                }
            }
            return out.size(); //返回读取到的字符个数
        }
};

说明一下: recv函数的最后一个参数如果设置为MSG_PEEK,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能。

二、读取请求报头和空行

  由于HTTP的请求报头和空行都是按行陈列的,因此可以循环调用ReadLine函数进行读取,并将读取到的每行数据都存储到HTTP请求类的request_header中,直到读取到空行为止。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //读取请求报头和空行
        void RecvHttpRequestHeader()
        {
            std::string line;
            while(true){
                line.clear(); //每次读取之前清空line
                Util::ReadLine(_sock, line);
                if(line == "
"){ //读取到了空行
                    _http_request._blank = line;
                    break;
                }
                //读取到一行请求报头
                line.resize(line.size() - 1); //去掉读取上来的

                _http_request._request_header.push_back(line);
            }
        }
};

说明一下:

由于ReadLine函数是将读取到的数据直接push_back到用户提供的缓冲区中的,因此每次调用ReadLine函数进行读取之前需要将缓冲区清空。
ReadLine函数会将行分隔符 一同读取上来,但对于我们来说 并不是有效数据,因此在将读取到的行存储到HTTP请求类的request_header中之前,需要先将 去掉。

三、解析请求行

  解析请求行要做的就是将请求行中的请求方法、URI和HTTP版本号拆分出来,依次存储到HTTP请求类的method、uri和version中,由于请求行中的这些数据都是以空格作为分隔符的,因此可以借助一个stringstream对象来进行拆分。此外,为了后续能够正确判断用户的请求方法,这里需要通过transform函数统一将请求方法转换为全大写。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //解析请求行
        void ParseHttpRequestLine()
        {
            auto& line = _http_request._request_line;

            //通过stringstream拆分请求行
            std::stringstream ss(line);
            ss>>_http_request._method>>_http_request._uri>>_http_request._version;

            //将请求方法统一转换为全大写
            auto& method = _http_request._method;
            std::transform(method.begin(), method.end(), method.begin(), toupper);
        }
};

四、解析请求报头

  解析请求报头要做的就是将读取到的一行一行的请求报头,以为分隔符拆分成一个个的键值对存储到HTTP请求的header_kv中,后续就可以直接通过属性名获取到对应的值了。

代码如下:

#define SEP ": "

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //解析请求报头
        void ParseHttpRequestHeader()
        {
            std::string key;
            std::string value;
            for(auto& iter : _http_request._request_header){
                //将每行请求报头打散成kv键值对,插入到unordered_map中
                if(Util::CutString(iter, key, value, SEP)){
                    _http_request._header_kv.insert({key, value});
                }
            }
        }
};

此处用于切割字符串的CutString函数也可以写到工具类中,切割字符串时先通过find方法找到指定的分隔符,然后通过substr提取切割后的子字符串即可。

代码如下:

//工具类
class Util{
    public:
        //切割字符串
        static bool CutString(std::string& target, std::string& sub1_out, std::string& sub2_out, std::string sep)
        {
            size_t pos = target.find(sep, 0);
            if(pos != std::string::npos){
                sub1_out = target.substr(0, pos);
                sub2_out = target.substr(pos + sep.size());
                return true;
            }
            return false;
        }
};

五、读取请求正文

  在读取请求正文之前,首先需要通过本次的请求方法来判断是否需要读取请求正文,因为只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。

  在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //判断是否需要读取请求正文
        bool IsNeedRecvHttpRequestBody()
        {
            auto& method = _http_request._method;
            if(method == "POST"){ //请求方法为POST则需要读取正文
                auto& header_kv = _http_request._header_kv;
                //通过Content-Length获取请求正文长度
                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 content_length = _http_request._content_length;
                auto& body = _http_request._request_body;

                //读取请求正文
                char ch = 0;
                while(content_length){
                    ssize_t size = recv(_sock, &ch, 1, 0);
                    if(size > 0){
                        body.push_back(ch);
                        content_length--;
                    }
                    else{
                        break;
                    }
                }
            }
        }
};

说明一下:

由于后续还会用到请求正文的长度,因此代码中将其存储到了HTTP请求类的content_length中。
在通过Content-Length获取到请求正文的长度后,需要将请求正文长度从字符串类型转换为整型。

处理HTTP请求

定义状态码

  在处理请求的过程中可能会因为某些原因而直接停止处理,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。为了告知客户端本次HTTP请求的处理情况,服务器需要定义不同的状态码,当处理请求被终止时就可以设置对应的状态码,后续构建HTTP响应的时候就可以根据状态码返回对应的错误页面。

状态码定义如下:

 #define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define INTERNAL_SERVER_ERROR 500

 处理HTTP请求

处理HTTP请求的步骤如下:

判断请求方法是否是正确,如果不正确则设置状态码为BAD_REQUEST后停止处理。
如果请求方法为GET方法,则需要判断URI中是否带参。如果URI不带参,则说明URI即为客户端请求的资源路径;如果URI带参,则需要以?为分隔符对URI进行字符串切分,切分后?左边的内容就是客户端请求的资源路径,而?右边的内容则是GET方法携带的参数,由于此时GET方法携带了参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
如果请求方法为POST方法,则说明URI即为客户端请求的资源路径,由于POST方法会通过请求正文上传参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
接下来需要对客户端请求的资源路径进行处理,首先需要在请求的资源路径前拼接上web根目录,然后需要判断请求资源路径的最后一个字符是否是/,如果是则说明客户端请求的是一个目录,这时服务器不会将该目录下全部的资源都返回给客户端,而是默认将该目录下的index.html返回给客户端,因此这时还需要在请求资源路径的后面拼接上index.html。
对请求资源的路径进行处理后,需要通过stat函数获取客户端请求资源文件的属性信息。如果客户端请求的是一个目录,则需要在请求资源路径的后面拼接上/index.html并重新获取资源文件的属性信息;如果客户端请求的是一个可执行程序,则说明后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
根据HTTP请求类中的cgi分别进行CGI或非CGI处理。
代码如下:

#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //处理请求
        void HandlerHttpRequest()
        {
            auto& code = _http_response._status_code;

            if(_http_request._method != "GET"&&_http_request._method != "POST"){ //非法请求
                LOG(WARNING, "method is not right");
                code = BAD_REQUEST; //设置对应的状态码,并直接返回
                return;
            }

            if(_http_request._method == "GET"){
                size_t pos = _http_request._uri.find('?');
                if(pos != std::string::npos){ //uri中携带参数
                    //切割uri,得到客户端请求资源的路径和uri中携带的参数
                    Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
                    _http_request._cgi = true; //上传了参数,需要使用CGI模式
                }
                else{ //uri中没有携带参数
                    _http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
                }
            }
            else if(_http_request._method == "POST"){
                _http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
                _http_request._cgi = true; //上传了参数,需要使用CGI模式
            }
            else{
                //Do Nothing
            }

            //给请求资源路径拼接web根目录
            std::string path = _http_request._path;
            _http_request._path = WEB_ROOT;
            _http_request._path += path;

            //请求资源路径以/结尾,说明请求的是一个目录
            if(_http_request._path[_http_request._path.size() - 1] == '/'){
                //拼接上该目录下的index.html
                _http_request._path += HOME_PAGE;
            }
            
            //获取请求资源文件的属性信息
            struct stat st;
            if(stat(_http_request._path.c_str(), &st) == 0){ //属性信息获取成功,说明该资源存在
                if(S_ISDIR(st.st_mode)){ //该资源是一个目录
                    _http_request._path += "/"; //需要拼接/,以/结尾的目录前面已经处理过了
                    _http_request._path += HOME_PAGE; //拼接上该目录下的index.html
                    stat(_http_request._path.c_str(), &st); //需要重新资源文件的属性信息
                }
                else if(st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH){ //该资源是一个可执行程序
                    _http_request._cgi = true; //需要使用CGI模式
                }
                _http_response._size = st.st_size; //设置请求资源文件的大小
            }
            else{ //属性信息获取失败,可以认为该资源不存在
                LOG(WARNING, _http_request._path + " NOT_FOUND");
                code = NOT_FOUND; //设置对应的状态码,并直接返回
                return;
            }

            //获取请求资源文件的后缀
            size_t pos = _http_request._path.rfind('.');
            if(pos == std::string::npos){
                _http_response._suffix = ".html"; //默认设置
            }
            else{
                _http_response._suffix = _http_request._path.substr(pos);
            }

            //进行CGI或非CGI处理
            if(_http_request._cgi == true){
                code = ProcessCgi(); //以CGI的方式进行处理
            }
            else{
                code = ProcessNonCgi(); //简单的网页返回,返回静态网页
            }
        }
};

说明一下:

本项目实现的HTTP服务器只支持GET方法和POST方法,因此如果客户端发来的HTTP请求中不是这两种方法则认为请求方法错误,如果想让服务器支持其他的请求方法则直接增加对应的逻辑即可。
服务器向外提供的资源都会放在web根目录下,比如网页、图片、视频等资源,本项目中的web根目录取名为wwwroot。web根目录下的所有子目录下都会有一个首页文件,当用户请求的资源是一个目录时,就会默认返回该目录下的首页文件,本项目中的首页文件取名为index.html。
stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为NOT_FOUND后停止处理即可。
当获取文件的属性信息后发现该文件是一个目录,此时请求资源路径一定不是以/结尾的,因为在此之前已经对/结尾的请求资源路径进行过处理了,因此这时需要给请求资源路径拼接上/index.html。
只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件,此时就需要将HTTP请求类中的cgi设置为true。
由于后续构建HTTP响应时需要用到请求资源文件的后缀,因此代码中对请求资源路径通过从后往前找.的方式,来获取请求资源文件的后缀,如果没有找到.则默认请求资源的后缀为.html。
由于请求资源文件的大小后续可能会用到,因此在获取到请求资源文件的属性后,可以将请求资源文件的大小保存到HTTP响应类的size中。

CGI处理

  CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。

示意图如下:

创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端:

对于父进程来说,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,那么子进程对应的文件描述符表的指向大致如下:

 现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道。

示意图如下:

此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递:

  • 首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
  • 如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
  • 如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。

此时子进程就可以进行进程程序替换了,而父进程需要做如下工作:

  • 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
  • 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
  • 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //CGI处理
        int ProcessCgi()
        {
            int code = OK; //要返回的状态码,默认设置为200

            auto& bin = _http_request._path;      //需要执行的CGI程序
            auto& method = _http_request._method; //请求方法

            //需要传递给CGI程序的参数
            auto& query_string = _http_request._query_string; //GET
            auto& request_body = _http_request._request_body; //POST

            int content_length = _http_request._content_length;  //请求正文的长度
            auto& response_body = _http_response._response_body; //CGI程序的处理结果放到响应正文当中

            //1、创建两个匿名管道(管道命名站在父进程角度)
            //创建从子进程到父进程的通信信道
            int input[2];
            if(pipe(input) < 0){ //管道创建失败,则返回对应的状态码
                LOG(ERROR, "pipe input error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }
            //创建从父进程到子进程的通信信道
            int output[2];
            if(pipe(output) < 0){ //管道创建失败,则返回对应的状态码
                LOG(ERROR, "pipe output error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }

            //2、创建子进程
            pid_t pid = fork();
            if(pid == 0){ //child
                //子进程关闭两个管道对应的读写端
                close(input[0]);
                close(output[1]);

                //将请求方法通过环境变量传参
                std::string method_env = "METHOD=";
                method_env += method;
                putenv((char*)method_env.c_str());

                if(method == "GET"){ //将query_string通过环境变量传参
                    std::string query_env = "QUERY_STRING=";
                    query_env += query_string;
                    putenv((char*)query_env.c_str());
                    LOG(INFO, "GET Method, Add Query_String env");
                }
                else if(method == "POST"){ //将正文长度通过环境变量传参
                    std::string content_length_env = "CONTENT_LENGTH=";
                    content_length_env += std::to_string(content_length);
                    putenv((char*)content_length_env.c_str());
                    LOG(INFO, "POST Method, Add Content_Length env");
                }
                else{
                    //Do Nothing
                }

                //3、将子进程的标准输入输出进行重定向
                dup2(output[0], 0); //标准输入重定向到管道的输入
                dup2(input[1], 1);  //标准输出重定向到管道的输出

                //4、将子进程替换为对应的CGI程序
                execl(bin.c_str(), bin.c_str(), nullptr);
                exit(1); //替换失败
            }
            else if(pid < 0){ //创建子进程失败,则返回对应的错误码
                LOG(ERROR, "fork error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }
            else{ //father
                //父进程关闭两个管道对应的读写端
                close(input[1]);
                close(output[0]);

                if(method == "POST"){ //将正文中的参数通过管道传递给CGI程序
                    const char* start = request_body.c_str();
                    int total = 0;
                    int size = 0;
                    while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0){
                        total += size;
                    }
                }

                //读取CGI程序的处理结果
                char ch = 0;
                while(read(input[0], &ch, 1) > 0){
                    response_body.push_back(ch);
                } //不会一直读,当另一端关闭后会继续执行下面的代码

                //等待子进程(CGI程序)退出
                int status = 0;
                pid_t ret = waitpid(pid, &status, 0);
                if(ret == pid){
                    if(WIFEXITED(status)){ //正常退出
                        if(WEXITSTATUS(status) == 0){ //结果正确
                            LOG(INFO, "CGI program exits normally with correct results");
                            code = OK;
                        }
                        else{
                            LOG(INFO, "CGI program exits normally with incorrect results");
                            code = BAD_REQUEST;
                        }
                    }
                    else{
                        LOG(INFO, "CGI program exits abnormally");
                        code = INTERNAL_SERVER_ERROR;
                    }
                }

                //关闭两个管道对应的文件描述符
                close(input[0]);
                close(output[1]);
            }
            return code; //返回状态码
        }
};

说明一下:

在CGI处理过程中,如果管道创建失败或者子进程创建失败,则属于服务器端处理请求时出错,此时返回INTERNAL_SERVER_ERROR状态码后停止处理即可。
环境变量是key=value形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key获取到对应的value。
子进程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日志信息,因为日志是以cout的方式打印到标准输出的,而dup2函数调用后标准输出已经被重定向到了管道,此时打印的日志信息将会被写入管道。
父进程循环调用read函数从管道中读取CGI程序的处理结果,当CGI程序执行结束时相当于写端进程将写端关闭了(文件描述符的生命周期随进程),此时读端进程将管道当中的数据读完后,就会继续执行后续代码,而不会被阻塞。
父进程在等待子进程退出后,可以通过WIFEXITED判断子进程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处理结果是否正确,然后根据不同情况设置对应的状态码(此时就算子进程异常退出或处理结果不正确也不能立即返回,需要让父进程继续向后执行,关闭两个管道对应的文件描述符,防止文件描述符泄露)。
非CGI处理

  非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但我们并不推荐这种做法。

  因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。

示意图如下:

 可以看到上述过程涉及数据在用户层和内核层的来回拷贝,但实际这个拷贝操作是不需要的,我们完全可以直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送。

示意图如下:

 要达到上述效果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。

  但是需要注意的是,这里还不能直接调用sendfile函数,因为sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP响应后再进行发送,因此我们这里要做的仅仅是将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //非CGI处理
        int ProcessNonCgi()
        {
            //打开客户端请求的资源文件,以供后续发送
            _http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
            if(_http_response._fd >= 0){ //打开文件成功
                return OK;
            }
            return INTERNAL_SERVER_ERROR; //打开文件失败
        }
};

说明一下: 如果打开文件失败,则返回INTERNAL_SERVER_ERROR状态码表示服务器处理请求时出错,而不能返回NOT_FOUND,因为之前调用stat获取过客户端请求资源的属性信息,说明该资源文件是一定存在的。

构建HTTP响应

构建HTTP响应

  构建HTTP响应首先需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP响应的status_line当中即可,而响应报头需要根据请求是否正常处理完毕分别进行构建。

代码如下:

#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "
"

#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //构建响应
        void BuildHttpResponse()
        {
            int 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 += CodeToDesc(code);
            status_line += LINE_END;

            //构建响应报头
            std::string path = WEB_ROOT;
            path += "/";
            switch(code){
                case OK:
                    BuildOkResponse();
                    break;
                case NOT_FOUND:
                    path += PAGE_404;
                    HandlerError(path);
                    break;
                case BAD_REQUEST:
                    path += PAGE_400;
                    HandlerError(path);
                    break;
                case INTERNAL_SERVER_ERROR:
                    path += PAGE_500;
                    HandlerError(path);
                    break;
                default:
                    break;
            }
        }
};

注意: 本项目中将服务器的行分隔符设置为 ,在构建完状态行以及每行响应报头之后都需要加上对应的行分隔符,而在HTTP响应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP响应时不用处理空行。

  对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述。

代码如下:

//根据状态码获取状态码描述
static std::string CodeToDesc(int code)
{
    std::string desc;
    switch(code){
        case 200:
            desc = "OK";
            break;
        case 400:
            desc = "Bad Request";
            break;
        case 404:
            desc = "Not Found";
            break;
        case 500:
            desc = "Internal Server Error";
            break;
        default:
            break;
    }
    return desc;
}

 构建响应报头(请求正常处理完毕)

  构建HTTP的响应报头时,我们至少需要构建Content-Type和Content-Length这两个响应报头,分别用于告知对方响应资源的类型和响应资源的长度。

  对于请求正常处理完毕的HTTP请求,需要根据客户端请求资源的后缀来得知返回资源的类型。而返回资源的大小需要根据该请求被处理的方式来得知,如果该请求是以非CGI方式进行处理的,那么返回资源的大小早已在获取请求资源属性时被保存到了HTTP响应类中的size当中,如果该请求是以CGI方式进行处理的,那么返回资源的大小应该是HTTP响应类中的response_body的大小。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        void BuildOkResponse()
        {
            //构建响应报头
            std::string content_type = "Content-Type: ";
            content_type += SuffixToDesc(_http_response._suffix);
            content_type += LINE_END;
            _http_response._response_header.push_back(content_type);

            std::string content_length = "Content-Length: ";
            if(_http_request._cgi){ //以CGI方式请求
                content_length += std::to_string(_http_response._response_body.size());
            }
            else{ //以非CGI方式请求
                content_length += std::to_string(_http_response._size);
            }
            content_length += LINE_END;
            _http_response._response_header.push_back(content_length);
        }
};

 对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        void BuildOkResponse()
        {
            //构建响应报头
            std::string content_type = "Content-Type: ";
            content_type += SuffixToDesc(_http_response._suffix);
            content_type += LINE_END;
            _http_response._response_header.push_back(content_type);

            std::string content_length = "Content-Length: ";
            if(_http_request._cgi){ //以CGI方式请求
                content_length += std::to_string(_http_response._response_body.size());
            }
            else{ //以非CGI方式请求
                content_length += std::to_string(_http_response._size);
            }
            content_length += LINE_END;
            _http_response._response_header.push_back(content_length);
        }
};

对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。

代码如下:

//根据后缀获取资源类型
static std::string SuffixToDesc(const std::string& suffix)
{
    static std::unordered_map suffix_to_desc = {
        {".html", "text/html"},
        {".css", "text/css"},
        {".js", "application/x-javascript"},
        {".jpg", "application/x-jpg"},
        {".xml", "text/xml"}
    };
    auto iter = suffix_to_desc.find(suffix);
    if(iter != suffix_to_desc.end()){
        return iter->second;
    }
    return "text/html"; //所给后缀未找到则默认该资源为html文件
}

 构建响应报头(请求处理出现错误)

  对于请求处理过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        void HandlerError(std::string page)
        {
            _http_request._cgi = false; //需要返回对应的错误页面(非CGI返回)

            //打开对应的错误页面文件,以供后续发送
            _http_response._fd = open(page.c_str(), O_RDONLY);
            if(_http_response._fd > 0){ //打开文件成功
                //构建响应报头
                struct stat st;
                stat(page.c_str(), &st); //获取错误页面文件的属性信息

                std::string content_type = "Content-Type: text/html";
                content_type += LINE_END;
                _http_response._response_header.push_back(content_type);

                std::string content_length = "Content-Length: ";
                content_length += std::to_string(st.st_size);
                content_length += LINE_END;
                _http_response._response_header.push_back(content_length);

                _http_response._size = st.st_size; //重新设置响应文件的大小
            }
        }
};

特别注意: 对于处理请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,因为后续发送HTTP响应时,需要根据HTTP请求类中的cgi来进行响应正文的发送,当请求处理出错后要返回给客户端的本质就是一个错误页面文件,相当于是以非CGI方式进行处理的。

发送HTTP响应

发送HTTP响应

发送HTTP响应的步骤如下:

调用send函数,依次发送状态行、响应报头和空行。
发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。
如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。
代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //发送响应
        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);
            //发送响应正文
            if(_http_request._cgi){
                auto& response_body = _http_response._response_body;
                const char* start = response_body.c_str();
                size_t size = 0;
                size_t total = 0;
                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_response._size);
                //关闭请求的资源文件
                close(_http_response._fd);
            }
        }
};

差错处理


  至此服务器逻辑其实已经已经走通了,但你会发现服务器在处理请求的过程中有时会莫名其妙的崩溃,根本原因就是当前服务器的错误处理还没有完全处理完毕。

逻辑错误

  逻辑错误主要是服务器在处理请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。逻辑错误其实我们已经处理过了,当出现这类错误时服务器会将对应的错误页面返回给客户端。

读取错误

  逻辑错误是在服务器处理请求时可能出现的错误,而在服务器处理请求之前首先要做的是读取请求,在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等。

  出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要进行后续的处理请求、构建响应以及发送响应的相关操作了。

  可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处理,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再进行后续的读取操作,因此读取HTTP请求的代码需要稍作修改。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
        bool _stop;                  //是否停止本次处理
    private:
        //读取请求行
        bool RecvHttpRequestLine()
        {
            auto& line = _http_request._request_line;
            if(Util::ReadLine(_sock, line) > 0){
                line.resize(line.size() - 1); //去掉读取上来的

            }
            else{ //读取出错,则停止本次处理
                _stop = true;
            }
            return _stop;
        }
        //读取请求报头和空行
        bool RecvHttpRequestHeader()
        {
            std::string line;
            while(true){
                line.clear(); //每次读取之前清空line
                if(Util::ReadLine(_sock, line) <= 0){ //读取出错,则停止本次处理
                    _stop = true;
                    break;
                }
                if(line == "
"){ //读取到了空行
                    _http_request._blank = line;
                    break;
                }
                //读取到一行请求报头
                line.resize(line.size() - 1); //去掉读取上来的

                _http_request._request_header.push_back(line);
            }
            return _stop;
        }
        //读取请求正文
        bool RecvHttpRequestBody()
        {
            if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
                int content_length = _http_request._content_length;
                auto& body = _http_request._request_body;

                //读取请求正文
                char ch = 0;
                while(content_length){
                    ssize_t size = recv(_sock, &ch, 1, 0);
                    if(size > 0){
                        body.push_back(ch);
                        content_length--;
                    }
                    else{ //读取出错或对端关闭,则停止本次处理
                        _stop = true;
                        break;
                    }
                }
            }
            return _stop;
        }
    public:
        EndPoint(int sock)
            :_sock(sock)
            ,_stop(false)
        {}
        //本次处理是否停止
        bool IsStop()
        {
            return _stop;
        }
        //读取请求
        void RecvHttpRequest()
        {
            if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值
                ParseHttpRequestLine();
                ParseHttpRequestHeader();
                RecvHttpRequestBody();
            }
        }
};

说明一下:

  • 可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,而当读取请求报头和空行成功后才需要进行后续的解析请求行、解析请求报头以及读取请求正文操作,这里利用到了逻辑运算符的短路求值策略。
  • EndPoint类当中提供了IsStop函数,用于让外部处理线程得知是否应该停止本次处理。

  此时服务器创建的新线程在读取请求后,就需要判断是否应该停止本次处理,如果需要则不再进行处理请求、构建响应以及发送响应操作,而直接关闭于客户端建立的套接字即可。

代码如下:

class CallBack{
    public:
        static void* HandlerRequest(void* arg)
        {
            LOG(INFO, "handler request begin");
            int sock = *(int*)arg;

            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest(); //读取请求
            if(!ep->IsStop()){
                LOG(INFO, "Recv No Error, Begin Handler Request");
                ep->HandlerHttpRequest(); //处理请求
                ep->BuildHttpResponse();  //构建响应
                ep->SendHttpResponse();   //发送响应
            }
            else{
                LOG(WARNING, "Recv Error, Stop Handler Request");
            }

            close(sock); //关闭与该客户端建立的套接字
            delete ep;

            LOG(INFO, "handler request end");
            return nullptr;
        }
};

写入错误

  除了读取请求时可能出现读取错误,处理请求时可能出现逻辑错误,在响应构建完毕发送响应时同样可能会出现写入错误,比如调用send发送响应时出错或发送响应时对方连接关闭等。

  出现写入错误时,服务器也没有必要继续进行发送了,这时需要直接设置stop为true并不再进行后续的发送操作,因此发送HTTP响应的代码也需要进行修改。

代码如下:

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //发送响应
        bool SendHttpResponse()
        {
            //发送状态行
            if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0){
                _stop = true; //发送失败,设置_stop
            }
            //发送响应报头
            if(!_stop){
                for(auto& iter : _http_response._response_header){
                    if(send(_sock, iter.c_str(), iter.size(), 0) <= 0){
                        _stop = true; //发送失败,设置_stop
                        break;
                    }
                }
            }
            //发送空行
            if(!_stop){
                if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0){
                    _stop = true; //发送失败,设置_stop
                }
            }
            //发送响应正文
            if(_http_request._cgi){
                if(!_stop){
                    auto& response_body = _http_response._response_body;
                    const char* start = response_body.c_str();
                    size_t size = 0;
                    size_t total = 0;
                    while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
                        total += size;
                    }
                }
            }
            else{
                if(!_stop){
                    if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){
                        _stop = true; //发送失败,设置_stop
                    }
                }
                //关闭请求的资源文件
                close(_http_response._fd);
            }
            return _stop;
        }
};

此外,当服务器发送响应出错时会收到SIGPIPE信号,而该信号的默认处理动作是终止当前进程,为了防止服务器因为写入出错而被终止,需要在初始化HTTP服务器时调用signal函数忽略SIGPIPE信号。

代码如下:

//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        //初始化服务器
        void InitServer()
        {
            signal(SIGPIPE, SIG_IGN); //忽略SIGPIPE信号,防止写入时崩溃
        }
};

接入线程池
当前多线程版服务器存在的问题:

每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。
这时可以在服务器端引入线程池:

在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
示意图如下:

设计任务

设计任务

  当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。

代码如下:

//任务类
class Task{
    private:
        int _sock;         //通信的套接字
        CallBack _handler; //回调函数
    public:
        Task()
        {}
        Task(int sock)
            :_sock(sock)
        {}
        //处理任务
        void ProcessOn()
        {
            _handler(_sock); //调用回调
        }
        ~Task()
        {}
};

说明一下: 任务类需要提供一个无参的构造函数,因为后续从任务队列中获取任务时,需要先以无参的方式定义一个任务对象,然后再以输出型参数的方式来获取任务。

编写任务回调

  任务类中处理任务时需要调用的回调函数,实际就是之前创建新线程时传入的执行例程CallBack::HandlerRequest,我们可以将CallBack类的()运算符重载为调用HandlerRequest函数,这时CallBack对象就变成了一个仿函数对象,这个仿函数对象被调用时实际就是在调用HandlerRequest函数。

代码如下:

class CallBack{
    public:
        CallBack()
        {}
        void operator()(int sock)
        {
            HandlerRequest(sock);
        }
        void HandlerRequest(int sock)
        {
            LOG(INFO, "handler request begin");

            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest(); //读取请求
            if(!ep->IsStop()){
                LOG(INFO, "Recv No Error, Begin Handler Request");
                ep->HandlerHttpRequest(); //处理请求
                ep->BuildHttpResponse();  //构建响应
                ep->SendHttpResponse();   //发送响应
                if(ep->IsStop()){
                    LOG(WARNING, "Send Error, Stop Send Response");
                }
            }
            else{
                LOG(WARNING, "Recv Error, Stop Handler Request");
            }

            close(sock); //关闭与该客户端建立的套接字
            delete ep;

            LOG(INFO, "handler request end");
        }
        ~CallBack()
        {}
};

 编写线程池
设计线程池结构

可以将线程池设计成单例模式:

  1. 将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
  2. 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
  3. 提供一个全局访问点获取单例对象,在单例对象第一次被获取时就创建这个单例对象并进行初始化。

ThreadPool类中的成员变量包括:

  • 任务队列:用于暂时存储未被处理的任务对象。
  • num:表示线程池中线程的个数。
  • 互斥锁:用于保证任务队列在多线程环境下的线程安全。
  • 条件变量:当任务队列中没有任务时,让线程在该条件变量下进行等等,当任务队列中新增任务时,唤醒在该条件变量下进行等待的线程。
  • 指向单例对象的指针:用于指向唯一的单例线程池对象。

ThreadPool类中的成员函数主要包括:

  • 构造函数:完成互斥锁和条件变量的初始化操作。
  • 析构函数:完成互斥锁和条件变量的释放操作。
  • InitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
  • PushTask:生产任务时调用,将任务对象放入任务队列,并唤醒在条件变量下等待的一个线程进行处理。
  • PopTask:消费任务时调用,从任务队列中获取一个任务对象。
  • ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不断检测任务队列中是否有任务,如果有则调用PopTask获取任务进行处理,如果没有则进行休眠直到被唤醒。
  • GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。

代码如下:

#define NUM 6

//线程池
class ThreadPool{
    private:
        std::queue _task_queue; //任务队列
        int _num;                     //线程池中线程的个数
        pthread_mutex_t _mutex;       //互斥锁
        pthread_cond_t _cond;         //条件变量
        static ThreadPool* _inst;     //指向单例对象的static指针
    private:
        //构造函数私有
        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;

        //判断任务队列是否为空
        bool IsEmpty()
        {
            return _task_queue.empty();
        }

        //任务队列加锁
        void LockQueue()
        {
            pthread_mutex_lock(&_mutex);
        }
        
        //任务队列解锁
        void UnLockQueue()
        {
            pthread_mutex_unlock(&_mutex);
        }

        //让线程在条件变量下进行等待
        void ThreadWait()
        {
            pthread_cond_wait(&_cond, &_mutex);
        }
        
        //唤醒在条件变量下等待的一个线程
        void ThreadWakeUp()
        {
            pthread_cond_signal(&_cond);
        }

    public:
        //获取单例对象
        static ThreadPool* GetInstance()
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
            //双检查加锁
            if(_inst == nullptr){
                pthread_mutex_lock(&mtx); //加锁
                if(_inst == nullptr){
                    //创建单例线程池对象并初始化
                    _inst = new ThreadPool();
                    _inst->InitThreadPool();
                }
                pthread_mutex_unlock(&mtx); //解锁
            }
            return _inst; //返回单例对象
        }

        //线程的执行例程
        static void* ThreadRoutine(void* arg)
        {
            pthread_detach(pthread_self()); //线程分离
            ThreadPool* tp = (ThreadPool*)arg;
            while(true){
                tp->LockQueue(); //加锁
                while(tp->IsEmpty()){
                    //任务队列为空,线程进行wait
                    tp->ThreadWait();
                }
                Task task;
                tp->PopTask(task); //获取任务
                tp->UnLockQueue(); //解锁

                task.ProcessOn(); //处理任务
            }
        }
        
        //初始化线程池
        bool InitThreadPool()
        {
            //创建线程池中的若干线程
            pthread_t tid;
            for(int i = 0;i < _num;i++){
                if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
                    LOG(FATAL, "create thread pool error!");
                    return false;
                }
            }
            LOG(INFO, "create thread pool success");
            return true;
        }
        
        //将任务放入任务队列
        void PushTask(const Task& task)
        {
            LockQueue();    //加锁
            _task_queue.push(task); //将任务推入任务队列
            UnLockQueue();  //解锁
            ThreadWakeUp(); //唤醒一个线程进行任务处理
        }

        //从任务队列中拿任务
        void PopTask(Task& task)
        {
            //获取任务
            task = _task_queue.front();
            _task_queue.pop();
        }

        ~ThreadPool()
        {
            //释放互斥锁和条件变量
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cond);
        }
};
//单例对象指针初始化为nullptr
ThreadPool* ThreadPool::_inst = nullptr;

说明一下:

  • 由于线程的执行例程的参数只能有一个void*类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才能够通过this指针访问任务队列。
  • 在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来保证线程安全,而线程在调用PopTask之前已经进行过加锁了,因此在PopTask函数中不必再加锁。
  • 当任务队列中有任务时会唤醒线程进行任务处理,为了防止被伪唤醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。

  引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。

代码如下:

//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        //启动服务器
        void Loop()
        {
            LOG(INFO, "loop begin");
            TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
            int listen_sock = tsvr->Sock(); //获取监听套接字
            while(true){
                struct sockaddr_in peer;
                memset(&peer, 0, sizeof(peer));
                socklen_t len = sizeof(peer);
                int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
                if(sock < 0){
                    continue; //获取失败,继续获取
                }

                //打印客户端相关信息
                std::string client_ip = inet_ntoa(peer.sin_addr);
                int client_port = ntohs(peer.sin_port);
                LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
                
                //构建任务并放入任务队列中
                Task task(sock);
                ThreadPool::GetInstance()->PushTask(task);
            }
        }
};

项目测试

服务器结构

  至此HTTP服务器后端逻辑已经全部编写完毕,此时我们要做的就是将对外提供的资源文件放在一个名为wwwroot的目录下,然后将生成的HTTP服务器可执行程序与wwwroot放在同级目录下。比如:

由于当前HTTP服务器没有任何业务逻辑,因此向外提供的资源文件只有三个错误页面文件,这些错误页面文件中的内容大致如下:



    
        
        404 Not Found
    
    
        

404 Not Found

对不起,你所要访问的资源不存在!

首页请求测试

服务器首页编写

  服务器的web根目录下的资源文件主要有两种,一种就是用于处理客户端上传上来的数据的CGI程序,另一种就是供客户端请求的各种网页文件了,而网页的制作实际是前端工程师要做的,但现在我们要对服务器进行测试,至少需要编写一个首页,首页文件需要放在web根目录下,取名为index.html。

以演示为主,首页的代码如下:




    
    
    
    Document
    


    

首页请求测试

  指定端口号运行服务器后可以看到一系列日志信息被打印出来,包括套接字创建成功、绑定成功、监听成功,这时底层用于通信的TCP服务器已经初始化成功了。

此时在浏览器上指定IP和端口访问我们的HTTP服务器,由于我们没有指定要访问服务器web根目录下的那个资源,此时服务器就会默认将web根目录下的index.html文件进行返回,浏览器收到index.html文件后经过刷新渲染就显示出了对应的首页页面。

 

同时服务器端也打印出了本次请求的一些日志信息。如下:

 

此时通过ps -aL命令可以看到线程池中的线程已经被创建好了,其中PID和LWP相同的就是主线程,剩下的就是线程池中处理任务的若干新线程。如下:

 

错误请求测试

错误请求测试

  如果我们请求的资源服务器并没有提供,那么服务器就会在获取请求资源属性信息时失败,这时服务器会停止本次请求处理,而直接将web根目录下的404.html文件返回浏览器,浏览器收到后经过刷新渲染就显示出了对应的404页面。

 

这时在服务器端就能看到一条日志级别为WARNING的日志信息,这条日志信息中说明了客户端请求的哪一个资源是不存在的。

 

 

本文地址:https://www.vps345.com/7200.html

搜索文章

Tags

PV计算 带宽计算 流量带宽 服务器带宽 上行带宽 上行速率 什么是上行带宽? CC攻击 攻击怎么办 流量攻击 DDOS攻击 服务器被攻击怎么办 源IP 服务器 linux 运维 游戏 云计算 javascript 前端 chrome edge llama 算法 opencv 自然语言处理 神经网络 语言模型 deepseek Ollama 模型联网 API CherryStudio 数据库 centos oracle 关系型 安全 分布式 RTSP xop RTP RTSPServer 推流 视频 科技 ai java 人工智能 个人开发 python MCP 进程 操作系统 进程控制 Ubuntu ssh 阿里云 网络 网络安全 网络协议 harmonyos 华为 开发语言 typescript 计算机网络 ubuntu HarmonyOS Next 运维开发 云原生 Flask FastAPI Waitress Gunicorn uWSGI Uvicorn YOLO efficientVIT YOLOv8替换主干网络 TOLOv8 macos adb debian PVE asm 宝塔面板访问不了 宝塔面板网站访问不了 宝塔面板怎么配置网站能访问 宝塔面板配置ip访问 宝塔面板配置域名访问教程 宝塔面板配置教程 vue.js audio vue音乐播放器 vue播放音频文件 Audio音频播放器自定义样式 播放暂停进度条音量调节快进快退 自定义audio覆盖默认样式 android 鸿蒙 ssl EtherCAT转Modbus ECT转Modbus协议 EtherCAT转485网关 ECT转Modbus串口网关 EtherCAT转485协议 ECT转Modbus网关 fastapi mcp mcp-proxy mcp-inspector fastapi-mcp agent sse 物联网 tcp/ip mcu iot 信息与通信 面试 性能优化 jdk intellij-idea 架构 filezilla 无法连接服务器 连接被服务器拒绝 vsftpd 331/530 智能路由器 外网访问 内网穿透 端口映射 pip conda ui 华为云 华为od 嵌入式 linux驱动开发 arm开发 嵌入式硬件 numpy rust http vscode ide 编辑器 vue3 HTML audio 控件组件 vue3 audio音乐播放器 Audio标签自定义样式默认 vue3播放音频文件音效音乐 自定义audio播放器样式 播放暂停调整声音大小下载文件 mount挂载磁盘 wrong fs type LVM挂载磁盘 Centos7.9 深度学习 pycharm 目标检测 protobuf 序列化和反序列化 安装 .netcore github 创意 社区 live555 源码剖析 rtsp实现步骤 流媒体开发 数据结构 学习 c语言 笔记 经验分享 学习方法 c# docker 容器 windows cpu 内存 实时 使用 AI 爬虫 数据集 后端 vim 命名管道 客户端与服务端通信 ipython flutter c++ 并查集 leetcode 前端框架 react.js 前端面试题 node.js 持续部署 YOLOv8 NPU Atlas800 A300I pro asi_bench VMware安装mocOS VMware macOS系统安装 mysql git elasticsearch jenkins 统信UOS 麒麟 bonding 链路聚合 WSL2 unix 产品经理 agi microsoft ddos qt stm32项目 单片机 stm32 开源 jmeter 软件测试 gpu算力 zotero WebDAV 同步失败 代理模式 ai小智 语音助手 ai小智配网 ai小智教程 智能硬件 esp32语音助手 diy语音助手 C 环境变量 进程地址空间 udp unity dell服务器 go golang sqlserver tomcat 低代码 大数据 数据挖掘 网络用户购物行为分析可视化平台 大数据毕业设计 1024程序员节 安装教程 GPU环境配置 Ubuntu22 CUDA PyTorch Anaconda安装 僵尸进程 websocket sql KingBase oceanbase rc.local 开机自启 systemd uni-app 智能手机 NAS Termux Samba Linux flask spring boot AI编程 AIGC DevEco Studio json chatgpt 大模型 llama3 Chatglm 开源大模型 java-ee php 政务 分布式系统 监控运维 Prometheus Grafana Dell R750XS 机器学习 postman mock mock server 模拟服务器 mock服务器 Postman内置变量 Postman随机数据 向日葵 pytorch Windsurf 电脑 .net tcpdump word图片自动上传 word一键转存 复制word图片 复制word图文 复制word公式 粘贴word图文 粘贴word公式 Agent jar gradle C# MQTTS 双向认证 emqx docker搭建nacos详解 docker部署nacos docker安装nacos 腾讯云搭建nacos centos7搭建nacos ESXi ESP32 camera Arduino 电子信息 计算机视觉 YOLOv12 豆瓣 追剧助手 迅雷 nas 微信 银河麒麟 kylin v10 麒麟 v10 LDAP kubernetes aws googlecloud Qwen2.5-coder 离线部署 ollama mac maven intellij idea rpc Dify MacOS录屏软件 HCIE 数通 nginx dubbo gateway Clion Nova ResharperC++引擎 Centos7 远程开发 pillow rag ragflow ragflow 源码启动 html5 firefox 微服务 springcloud rust腐蚀 WSL win11 无法解析服务器的名称或地址 cuda cudnn anaconda ffmpeg 音视频 程序人生 vSphere vCenter 软件定义数据中心 sddc 迁移指南 鸿蒙系统 温湿度数据上传到服务器 Arduino HTTP DeepSeek-R1 API接口 计算机外设 软件需求 客户端 k8s rtsp rtp 自动化 Hyper-V WinRM TrustedHosts GaN HEMT 氮化镓 单粒子烧毁 辐射损伤 辐照效应 微信分享 Image wxopensdk ue5 vr CPU 主板 电源 网卡 压测 ECS web安全 ssrf 失效的访问控制 openwrt 5G 3GPP 卫星通信 命令 mq rabbitmq rocketmq kafka r语言 数据可视化 ip命令 新增网卡 新增IP 启动网卡 hadoop 远程桌面 大文件分片上传断点续传及进度条 如何批量上传超大文件并显示进度 axios大文件切片上传详细教 node服务器合并切片 vue3大文件上传报错提示错误 大文件秒传跨域报错cors 小艺 Pura X excel xrdp 远程连接 webstorm 系统架构 string模拟实现 深拷贝 浅拷贝 经典的string类问题 三个swap 开发环境 SSL证书 虚拟化 半虚拟化 硬件虚拟化 Hypervisor eureka bash selenium 测试工具 能力提升 面试宝典 技术 IT信息化 自动驾驶 环境迁移 缓存 Docker Hub docker pull 镜像源 daemon.json 强制清理 强制删除 mac废纸篓 NFS redhat 群晖 文件分享 devops ci/cd odoo 服务器动作 Server action NPS 雨云服务器 雨云 游戏程序 ios AnythingLLM AnythingLLM安装 远程控制 远程看看 远程协助 xml outlook 直播推流 Ark-TS语言 zabbix 毕设 LLM 大模型面经 职场和发展 Deepseek 大模型学习 rime yum django cmos 硬件 W5500 OLED u8g2 TCP服务器 网络结构图 驱动开发 gitea 媒体 微信公众平台 C语言 mamba express svn 三级等保 服务器审计日志备份 apache 状态管理的 UDP 服务器 Arduino RTOS html css 远程 执行 sshpass 操作 Invalid Host allowedHosts vue matlab 重启 排查 系统重启 日志 原因 bootstrap redis 机器人 负载均衡 jupyter prometheus 监控k8s集群 集群内prometheus arkUI VMware安装Ubuntu Ubuntu安装k8s mybatis OD机试真题 华为OD机试真题 服务器能耗统计 gpt 信号处理 网络药理学 生信 生物信息学 gromacs 分子动力学模拟 MD 动力学模拟 压力测试 jellyfin ArcTS 登录 ArcUI GridItem openvpn server openvpn配置教程 centos安装openvpn transformer RAGFLOW vmware 卡死 DeepSeek Cline 自动化编程 智能音箱 智能家居 其他 webrtc ssh漏洞 ssh9.9p2 CVE-2025-23419 远程工作 课程设计 系统开发 binder 车载系统 framework 源码环境 k8s集群资源管理 云原生开发 ros2 moveit 机器人运动 spring thingsboard postgresql 宝塔面板 部署 dba Linux PID n8n dity make pygame 小游戏 五子棋 okhttp 硬件架构 wireshark 显示过滤器 ICMP Wireshark安装 list IIS .net core Hosting Bundle .NET Framework vs2022 交换机 telnet 远程登录 测试用例 功能测试 shell 磁盘监控 服务器配置 nvidia openEuler CentOS Stream CentOS 软件工程 实战案例 https k8s资源监控 annotations自动化 自动化监控 监控service 监控jvm 蓝桥杯 kylin 混合开发 环境安装 JDK rdp 实验 fpga开发 王者荣耀 Wi-Fi DNS 华为认证 网络工程师 拓扑图 mysql离线安装 ubuntu22.04 mysql8.0 AI大模型 spring cloud 源码 毕业设计 visual studio code linux上传下载 llm 无人机 程序员 centos-root /dev/mapper yum clean all df -h / du -sh 云电竞 云电脑 todesk TRAE 京东云 P2P HDLC 思科 设备 GPU PCI-Express Google pay Apple pay 命令行 基础入门 编程 微信小程序 小程序 服务器主板 AI芯片 hive Hive环境搭建 hive3环境 Hive远程模式 Linux无人智慧超市 LInux多线程服务器 QT项目 LInux项目 单片机项目 UOS 统信操作系统 android studio 交互 ping++ 深度优先 图论 并集查找 换根法 树上倍增 Redis Desktop 代码调试 ipdb SSH RAID RAID技术 磁盘 存储 消息队列 dify chrome 浏览器下载 chrome 下载安装 谷歌浏览器下载 Trae AI代码编辑器 SWAT 配置文件 服务管理 网络共享 gaussdb ruoyi rclone AList webdav fnOS DeepSeek行业应用 Heroku 网站部署 实时音视频 实时互动 视觉检测 docker compose 游戏机 VMware创建虚拟机 ocr micropython esp32 mqtt 飞牛NAS 飞牛OS MacBook Pro 中间件 可信计算技术 安全架构 网络攻击模型 nuxt3 matplotlib Ubuntu Server Ubuntu 22.04.5 IIS服务器 IIS性能 日志监控 react next.js 部署next.js gpt-3 文心一言 设计模式 腾讯云 串口服务器 Reactor C++ 企业微信 Linux24.04 deepin 银河麒麟服务器操作系统 系统激活 金融 数据分析 safari Mac 系统 算力 ansible 腾讯云大模型知识引擎 etcd 数据安全 RBAC 产测工具框架 IMX6ULL 管理框架 线程 多线程 windwos防火墙 defender防火墙 win防火墙白名单 防火墙白名单效果 防火墙只允许指定应用上网 防火墙允许指定上网其它禁止 高效远程协作 TrustViewer体验 跨设备操作便利 智能远程控制 报错 Java hibernate 开发 grafana gitlab IPMI springboot远程调试 java项目远程debug docker远程debug java项目远程调试 springboot远程 KylinV10 麒麟操作系统 虚拟机 Vmware 漏洞 kind 同步 备份 建站 安全威胁分析 JAVA iftop 网络流量监控 Xterminal 安卓 bug 弹性计算 云服务器 裸金属服务器 弹性裸金属服务器 p2p iBMC UltraISO 模拟实现 lio-sam SLAM unity3d uv 网络穿透 wps dns SenseVoice 大模型入门 致远OA OA服务器 服务器磁盘扩容 多进程 Node-Red 编程工具 流编程 CORS 跨域 threejs 3D linux安装配置 大模型微调 firewalld pdf 办公自动化 自动化生成 pdf教程 ecmascript KVM Vmamba arm 工作流 workflow code-server MQTT mosquitto Cookie CH340 串口驱动 CH341 uart 485 sqlite MS Materials arcgis openssl 密码学 监控 自动化运维 kamailio sip VoIP mariadb 大数据平台 医疗APP开发 app开发 Portainer搭建 Portainer使用 Portainer使用详解 Portainer详解 Portainer portainer virtualenv echarts 信息可视化 网页设计 ue4 着色器 虚幻 AP配网 AK配网 小程序AP配网和AK配网教程 WIFI设备配网小程序UDP开 pyautogui mongodb AISphereButler 开机自启动 ShenTong 国产化 大模型教程 QQ bot Docker remote-ssh 国产操作系统 ukui 麒麟kylinos openeuler 统信 虚拟机安装 框架搭建 Java Applet URL操作 服务器建立 Socket编程 网络文件读取 VPS OpenManus pyqt 微信小程序域名配置 微信小程序服务器域名 微信小程序合法域名 小程序配置业务域名 微信小程序需要域名吗 微信小程序添加域名 kali 共享文件夹 RustDesk自建服务器 rustdesk服务器 docker rustdesk web3.py 飞书 孤岛惊魂4 WebRTC 图像处理 恒源云 ux RTMP 应用层 进程信号 CLion IDE 系统安全 Open WebUI 灵办AI kvm 游戏服务器 TrinityCore 魔兽世界 asp.net大文件上传 asp.net大文件上传源码 ASP.NET断点续传 asp.net上传文件夹 asp.net上传大文件 .net core断点续传 .net mvc断点续传 wsl 大语言模型 adobe 传统数据库升级 银行 LLMs Python 网络编程 聊天服务器 套接字 TCP Socket ollama下载加速 服务器繁忙 单元测试 集成测试 IPMITOOL BMC 硬件管理 opcua opcda KEPServer安装 oneapi FTP 服务器 矩阵 服务器管理 配置教程 服务器安装 网站管理 playbook 剧本 linux 命令 sed 命令 换源 国内源 Debian RoboVLM 通用机器人策略 VLA设计哲学 vlm fot robot 视觉语言动作模型 具身智能 iis 多线程服务器 Linux网络编程 云服务 springboot springsecurity6 oauth2 授权服务器 token sas visualstudio 银河麒麟操作系统 WebUI DeepSeek V3 工业4.0 rsyslog 权限 wsl2 npm trae 博客 服务器数据恢复 数据恢复 存储数据恢复 raid5数据恢复 磁盘阵列数据恢复 多层架构 解耦 AI 原生集成开发环境 Trae AI AI写作 prompt minicom 串口调试工具 硬件工程 嵌入式实习 fd 文件描述符 蓝耘科技 元生代平台工作流 ComfyUI 微信开放平台 微信公众号配置 log4j gitee yum源切换 更换国内yum源 本地部署 api DigitalOcean GPU服务器购买 GPU服务器哪里有 GPU服务器 Kylin-Server 交叉编译 EasyConnect Kali Linux 黑客 渗透测试 信息收集 nextjs reactjs 黑客技术 流式接口 分析解读 免费域名 域名解析 搜索引擎 minio MI300x docker命令大全 毕昇JDK DocFlow 上传视频至服务器代码 vue3批量上传多个视频并预览 如何实现将本地视频上传到网页 element plu视频上传 ant design vue vue3本地上传视频及预览移除 SSE 信号 ubuntu24 vivado24 LLM Web APP Streamlit 网卡的名称修改 eth0 ens33 边缘计算 网工 vue-i18n 国际化多语言 vue2中英文切换详细教程 如何动态加载i18n语言包 把语言json放到服务器调用 前端调用api获取语言配置文件 SRS 流媒体 直播 Linux的权限 怎么卸载MySQL MySQL怎么卸载干净 MySQL卸载重新安装教程 MySQL5.7卸载 Linux卸载MySQL8.0 如何卸载MySQL教程 MySQL卸载与安装 Deepseek-R1 私有化部署 推理模型 elk MacMini 迷你主机 mini Apple 宠物 免费学习 宠物领养 宠物平台 常用命令 文本命令 目录命令 崖山数据库 YashanDB python3.11 视频编解码 Ubuntu 24.04.1 轻量级服务器 Ubuntu 24 常用命令 Ubuntu 24 Ubuntu vi 异常处理 dash 正则表达式 pgpool langchain deep learning 监控k8s 监控kubernetes 端口测试 自动化测试 性能测试 田俊楠 健康医疗 netty VR手套 数据手套 动捕手套 动捕数据手套 c 远程过程调用 Windows环境 es jvm 高效日志打印 串口通信日志 服务器日志 系统状态监控日志 异常记录日志 单例模式 Claude 程序员创富 实习 相差8小时 UTC 时间 qemu libvirt 3d 数学建模 无桌面 risc-v 反向代理 cnn DenseNet Jellyfin 输入法 CrewAI av1 电视盒子 机顶盒ROM 魔百盒刷机 swoole 虚拟显示器 FTP服务器 sonoma 自动更新 联想开天P90Z装win10 xshell termius iterm2 rustdesk ros neo4j 数据仓库 数据库开发 数据库架构 database can 线程池 web 金仓数据库 2025 征文 数据库平替用金仓 增强现实 沉浸式体验 应用场景 技术实现 案例分析 AR 软考 计算机 GoogLeNet keepalived RAGFlow 互信 ecm bpm 鲲鹏 昇腾 npu ssh远程登录 gcc 游戏引擎 虚幻引擎 宕机切换 服务器宕机 chrome devtools chromedriver 执法记录仪 智能安全帽 smarteye xcode USB网络共享 Minecraft DOIT 四博智联 centos 7 docker run 数据卷挂载 交互模式 北亚数据恢复 oracle数据恢复 知识图谱 Cursor MCP server C/S windows日志 本地部署AI大模型 技能大赛 LInux XCC Lenovo 繁忙 解决办法 替代网站 汇总推荐 AI推理 H3C iDRAC R720xd selete 高级IO freebsd HarmonyOS OpenHarmony 真机调试 黑苹果 环境配置 sdkman sequoiaDB skynet 服务器无法访问 ip地址无法访问 无法访问宝塔面板 宝塔面板打不开 eNSP 网络规划 VLAN 企业网络 IM即时通讯 剪切板对通 HTML FORMAT XFS xfs文件系统损坏 I_O error Kali linux环境变量 embedding 图形渲染 前后端分离 人工智能生成内容 prometheus数据采集 prometheus数据模型 prometheus特点 uni-file-picker 拍摄从相册选择 uni.uploadFile H5上传图片 微信小程序上传图片 DBeaver kerberos 相机 FunASR ASR file server http server web server 阿里云ECS 云桌面 微软 AD域控 证书服务器 alias unalias 别名 LORA NLP searxng PPI String Cytoscape CytoHubba 个人博客 X11 Xming Docker引擎已经停止 Docker无法使用 WSL进度一直是0 镜像加速地址 集成学习 openstack Xen 图形化界面 Docker Compose docker-compose 我的世界 我的世界联机 数码 互联网医院 less 本地化部署 ISO镜像作为本地源 考研 Playwright 阻塞队列 生产者消费者模型 服务器崩坏原因 nac 802.1 portal QT 5.12.12 QT开发环境 Ubuntu18.04 jetty undertow grub 版本升级 扩容 私有化 ROS 聚类 程序 性能分析 磁盘镜像 服务器镜像 服务器实时复制 实时文件备份 Erlang OTP gen_server 热代码交换 事务语义 MNN Qwen flash-attention ip 备份SQL Server数据库 数据库备份 傲梅企业备份网络版 音乐服务器 Navidrome 音流 Netty 即时通信 NIO 链表 强化学习 tidb GLIBC dns是什么 如何设置电脑dns dns应该如何设置 银河麒麟桌面操作系统 Kylin OS MQTT协议 消息服务器 代码 在线预览 xlsx xls文件 在浏览器直接打开解析xls表格 前端实现vue3打开excel 文件地址url或接口文档流二进 yaml Ultralytics 可视化 虚拟局域网 ceph MySql pppoe radius hugo AI agent 思科模拟器 Cisco sqlite3 显卡驱动 大模型应用 邮件APP 免费软件 Linux的基础指令 saltstack vasp安装 AI作画 深度求索 私域 知识库 聊天室 信创 信创终端 中科方德 jina 国标28181 视频监控 监控接入 语音广播 流程 SIP SDP 匿名管道 frp seatunnel Radius wordpress 无法访问wordpess后台 打开网站页面错乱 linux宝塔面板 wordpress更换服务器 历史版本 下载 序列化反序列化 qt项目 qt项目实战 qt教程 etl muduo SSH 密钥生成 SSH 公钥 私钥 生成 计算虚拟化 弹性裸金属 edge浏览器 ruby 物联网开发 seleium 社交电子 deepseek r1 EMQX 通信协议 composer 直流充电桩 充电桩 make命令 makefile文件 c/c++ 串口 SEO chfs ubuntu 16.04 小番茄C盘清理 便捷易用C盘清理工具 小番茄C盘清理的优势尽显何处? 教你深度体验小番茄C盘清理 C盘变红?!不知所措? C盘瘦身后电脑会发生什么变化? 显示管理器 lightdm gdm iphone vscode 1.86 双系统 GRUB引导 Linux技巧 火绒安全 Nuxt.js 内网服务器 内网代理 内网通信 英语 GCC aarch64 编译安装 HPC 数据管理 数据治理 数据编织 数据虚拟化 镜像 idm 文件系统 用户缓冲区 支付 微信支付 开放平台 glibc HTTP 服务器控制 ESP32 DeepSeek 域名服务 DHCP 符号链接 配置 备选 网站 调用 示例 路径解析 ubuntu24.04.1 AD域 软负载 tcp HiCar CarLife+ CarPlay QT RK3588 yolov8 cocoapods 树莓派 VNC rnn 版本 Attention fast AutoDL 渗透 RAG 检索增强生成 文档解析 大模型垂直应用 业界资讯 模拟退火算法 运维监控 Ubuntu22.04 开发人员主页 银河麒麟高级服务器 外接硬盘 Kylin trea idea 技术共享 华为机试 bcompare Beyond Compare 目标跟踪 OpenVINO 推理应用 模拟器 教程 epoll 数据库系统 自定义客户端 SAS 代理 SysBench 基准测试 回显服务器 UDP的API使用 cursor 做raid 装系统 armbian u-boot 升级 CVE-2024-7347 VM搭建win2012 win2012应急响应靶机搭建 攻击者获取服务器权限 上传wakaung病毒 应急响应并溯源 挖矿病毒处置 应急响应综合性靶场 宝塔 键盘 EMUI 回退 降级 cd 目录切换 gnu ftp 嵌入式系统开发 tensorflow AI-native Docker Desktop uniapp web3 代理服务器 Dell HPE 联想 浪潮 指令 vscode1.86 1.86版本 ssh远程连接 open Euler dde x64 SIGSEGV xmm0 策略模式 miniapp 调试 debug 断点 网络API请求调试方法 big data apt opensearch helm open webui IMM sublime text Linux awk awk函数 awk结构 awk内置变量 awk参数 awk脚本 awk详解 xpath定位元素 curl wget 单一职责原则 设置代理 实用教程 远程服务 spark HistoryServer Spark YARN jobhistory conda配置 conda镜像源 Ubuntu DeepSeek DeepSeek Ubuntu DeepSeek 本地部署 DeepSeek 知识库 DeepSeek 私有化知识库 本地部署 DeepSeek DeepSeek 私有化部署 asp.net大文件上传下载 VSCode 移动云 crosstool-ng 大模型部署 easyui nlp zookeeper nfs 服务器部署ai模型 SSL 域名 Anolis nginx安装 linux插件下载 SSH 服务 SSH Server OpenSSH Server 稳定性 看门狗 自动化任务管理 UOS1070e 小智AI服务端 xiaozhi TTS GIS 遥感 WebGIS AD 域管理 飞牛nas fnos 网站搭建 serv00 推荐算法 大大通 第三代半导体 碳化硅 Windows ai工具 v10 软件 ldap 多个客户端访问 IO多路复用 TCP相关API ArkTs ArkUI h.264 Xinference URL Mac内存不够用怎么办 Linux环境 进程优先级 调度队列 进程切换 我的世界服务器搭建 minecraft cpp-httplib 防火墙 NAT转发 NAT Server Unity Dedicated Server Host Client 无头主机 perl fonts-noto-cjk 李心怡 Python基础 Python教程 Python技巧 axure 富文本编辑器 WLAN maxkb ARG 影刀 #影刀RPA# Mermaid 可视化图表 代码托管服务 css3 软链接 硬链接 sentinel su sudo 区块链 midjourney hosts figma WebVM 基础环境 流水线 脚本式流水线 热榜 ubuntu20.04 开机黑屏 7z 佛山戴尔服务器维修 佛山三水服务器维修 存储维护 NetApp存储 EMC存储 TrueLicense Zoertier 内网组网 加解密 Yakit yaklang 沙盒 干货分享 黑客工具 密码爆破 超融合 服务器时间 C++软件实战问题排查经验分享 0xfeeefeee 0xcdcdcdcd 动态库加载失败 程序启动失败 程序运行权限 标准用户权限与管理员权限 服务网格 istio virtualbox Ubuntu共享文件夹 共享目录 Linux共享文件夹 像素流送api 像素流送UE4 像素流送卡顿 像素流送并发支持 HarmonyOS NEXT 原生鸿蒙 tailscale derp derper 中转 triton 模型分析 线性代数 电商平台 语音识别 浏览器开发 AI浏览器 鸿蒙开发 移动开发 rancher 安防软件 CDN 烟花代码 烟花 元旦 语法 perf linux内核 本地知识库部署 DeepSeek R1 模型 lsb_release /etc/issue /proc/version uname -r 查看ubuntu版本 iventoy VmWare OpenEuler 安卓模拟器 网络建设与运维 网络搭建 神州数码 神州数码云平台 云平台 react native 捆绑 链接 谷歌浏览器 youtube google gmail 磁盘清理 powerpoint 本地环回 bind 多路转接 firewall deekseek regedit 开机启动 cfssl TCP协议 抗锯齿 nftables Spring Security word rtsp服务器 rtsp server android rtsp服务 安卓rtsp服务器 移动端rtsp服务 大牛直播SDK webgl Logstash 日志采集 游戏开发 onlyoffice 在线office 带外管理 Typore milvus 大模型技术 本地部署大模型 玩机技巧 软件分享 软件图标 MVS 海康威视相机 大模型推理 项目部署 lb 协议 dock 加速 xss db 移动魔百盒 USB转串口 搭建个人相关服务器 harmonyOS面试题 OpenSSH 分布式训练 软件构建 SVN Server tortoise svn IPv4 子网掩码 公网IP 私有IP 主从复制 iperf3 带宽测试 K8S k8s管理系统 根服务器 clickhouse HAProxy junit IDEA 上传视频文件到服务器 uniApp本地上传视频并预览 uniapp移动端h5网页 uniapp微信小程序上传视频 uniapp app端视频上传 uniapp uview组件库 虚拟现实 计算生物学 生物信息 基因组 laravel 需求分析 规格说明书 lua PX4 navicat 自学笔记 小米 澎湃OS Android 音乐库 飞牛 IO模型 读写锁 AI Agent 字节智能运维 查询数据库服务IP地址 SQL Server 端口 查看 ss deployment daemonset statefulset cronjob EtherNet/IP串口网关 EIP转RS485 EIP转Modbus EtherNet/IP网关协议 EIP转RS485网关 EIP串口服务器 kernel g++ g++13 DIFY wpf 智能电视 Qwen2.5-VL vllm k8s二次开发 集群管理 flink IMX317 MIPI H265 VCU yolov5 eclipse 嵌入式Linux IPC DeepSeek r1 AzureDataStudio 项目部署到linux服务器 项目部署过程 中兴光猫 换光猫 网络桥接 自己换光猫 llama.cpp 多端开发 智慧分发 应用生态 鸿蒙OS NLP模型 autodl win服务器架设 windows server Doris搭建 docker搭建Doris Doris搭建过程 linux搭建Doris Doris搭建详细步骤 Doris部署 sysctl.conf vm.nr_hugepages bat 状态模式 docker部署翻译组件 docker部署deepl docker搭建deepl java对接deepl 翻译组件使用 Headless Linux 元服务 应用上架 离线部署dify docker部署Python VS Code 僵尸世界大战 游戏服务器搭建 GameFramework HybridCLR Unity编辑器扩展 自动化工具 企业网络规划 华为eNSP ArtTS WSL2 上安装 Ubuntu vpn java-rocketmq 架构与原理 banner opengl 内网环境 kotlin 风扇控制软件 ELF加载 Web服务器 多线程下载工具 PYTHON 浪潮信息 AI服务器 cmake Web应用服务器 windows 服务器安装 CosyVoice vnc 华为证书 HarmonyOS认证 华为证书考试 Helm k8s集群 mm-wiki搭建 linux搭建mm-wiki mm-wiki搭建与使用 mm-wiki使用 mm-wiki详解 zip unzip fork wait waitpid exit ebpf uprobe MAVROS 四旋翼无人机 ABAP yum换源 网络爬虫 Carla 智能驾驶 Linux 维护模式 工具 电视剧收视率分析与可视化平台 python2 ubuntu24.04 scapy 流量运营 java-rabbitmq ranger MySQL8.0 内核 问题解决 显示器 samba 极限编程 Sealos 合成模型 扩散模型 图像生成 论文阅读 安装MySQL 通信工程 毕业 fstab scikit-learn burp suite 抓包 多产物 proxy模式 软件卸载 系统清理 Reactor反应堆 性能调优 安全代理 iNode Macos mcp服务器 client close Unity插件 copilot 网页服务器 web服务器 Nginx 网络文件系统 export import save load 迁移镜像 lvm 磁盘挂载 磁盘分区 浏览器自动化 ip协议 visual studio top Linux top top命令详解 top命令重点 top常用参数 nvm whistle UDP 输入系统 vu大文件秒传跨域报错cors ubuntu 18.04 ROS2 Mac软件 机柜 1U 2U 宝塔面板无法访问 Apache Beam 批流统一 案例展示 数据分区 容错机制 大屏端 shell脚本免交互 expect linux免交互 netlink libnl3 容器技术 React Next.js 开源框架 stable diffusion 云耀服务器 docker搭建pg docker搭建pgsql pg授权 postgresql使用 postgresql搭建 达梦 DM8 粘包问题 对比 meld DiffMerge 服务器安全 网络安全策略 防御服务器攻击 安全威胁和解决方案 程序员博客保护 数据保护 安全最佳实践 联机 僵尸毁灭工程 游戏联机 开服 欧标 OCPP powerbi 生活 ros1 Noetic 20.04 apt 安装 Alexnet 话题通信 服务通信 IO 开源软件 图片增强 增强数据 硅基流动 ChatBox rpa wsgiref Web 服务器网关接口 国产数据库 瀚高数据库 数据迁移 下载安装 oracle fusion oracle中间件 cron crontab日志 notepad ardunio BLE WebServer Linux find grep 钉钉 端口聚合 windows11 隐藏文件 隐藏目录 管理器 通配符 抓包工具 System V共享内存 进程通信 tar docker desktop image qt5 客户端开发 AI员工 智慧农业 开源鸿蒙 团队开发 跨平台 mysql安装报错 windows拒绝安装 服务器扩容没有扩容成功 授时服务 北斗授时 Linux权限 权限命令 特殊权限 MDK 嵌入式开发工具 论文笔记 CPU 使用率 系统监控工具 linux 命令 视频平台 录像 视频转发 视频流 MAC SecureCRT hexo 查看显卡进程 fuser 服务器部署 本地拉取打包 nosql UEFI Legacy MBR GPT U盘安装操作系统 MacOS MobaXterm 文件传输 卷积神经网络 弹性服务器 WINCC 零售 联网 easyconnect 服务器ssl异常解决 Chatbox csrutil mac恢复模式进入方法 恢复模式 js VMware Tools vmware tools安装 vmwaretools安装步骤 vmwaretools安装失败 vmware tool安装步骤 vm tools安装步骤 vm tools安装后不能拖 vmware tools安装步骤 西门子PLC 通讯 蓝牙 代码规范 xfce macOS ubuntu安装 linux入门小白 zerotier NFC 近场通讯 智能门锁 录音麦克风权限判断检测 录音功能 录音文件mp3播放 小程序实现录音及播放功能 RecorderManager 解决录音报错播放没声音问题 工具分享 搜狗输入法 中文输入法 数字证书 签署证书 镜像下载 Docker快速入门 解决方案 服务器正确解析请求体 接口优化 DevOps 软件交付 数据驱动 lighttpd安装 Ubuntu配置 Windows安装 服务器优化