实战整了一个后台服务,真香!
大家好,HTTP 服务是重中之重,今天分享一下 一个 HTTP 服务的实现。
项目介绍
本项目实现的是一个 HTTP 服务器,项目中将会通过基本的网络套接字读取客户端发来的 HTTP 请求并进行分析,最终构建 HTTP 响应并返回给客户端。
HTTP 在网络应用层中的地位是不可撼动的,无论是移动端还是 PC 端浏览器,HTTP 无疑是打开互联网应用窗口的重要协议。
该项目将会把 HTTP 中最核心的模块抽取出来,采用 CS 模型实现一个小型的 HTTP 服务器,目的在于理解 HTTP 协议的处理过程。
该项目主要涉及 C/C++、HTTP 协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技术。
网络协议栈介绍
协议分层
网络协议栈的分层情况如下:
网络协议栈中各层的功能如下:
应用层:根据特定的通信目的,对数据进行分析处理,以达到某种业务性的目的。
传输层:处理传输时遇到的问题,主要是保证数据传输的可靠性。
网络层:完成数据的转发,解决数据去哪里的问题。
链路层:负责数据真正的发生过程。
数据的封装与分用
数据封装与分用的过程如下:
也就是说,发送端在发生数据前,该数据需要先自顶向下贯穿网络协议栈完成数据的封装,在这个过程中,每一层协议都会为该数据添加上对应的报头信息。接收端在收到数据后,该数据需要先自底向上贯穿网络协议栈完成数据的解包和分用,在这个过程中,每一层协议都会将对应的报头信息提取出来。
而本项目要做的就是,在接收到客户端发来的 HTTP 请求后,将 HTTP 的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上 HTTP 报头再发送给客户端。
需要注意的是,该项目中我们所处的位置是应用层,因此我们读取的 HTTP 请求实际是从传输层读取上来的,而我们发送的 HTTP 响应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成,这里直接说“接收到客户端的 HTTP 请求”以及“发送 HTTP 响应给客户端”,只是为了方便大家理解,此外,同层协议之间本身也是可以理解成是在直接通信的。
HTTP 相关知识介绍
HTTP 的特点
HTTP 的五大特点如下:
客户端服务器模式(CS,BS):在一条通信线路上必定有一端是客户端,另一端是服务器端,请求从客户端发出,服务器响应请求并返回。
简单快速:客户端向服务器请求服务时,只需传送请求方法和请求资源路径,不需要发送额外过多的数据,并且由于 HTTP 协议结构较为简单,使得 HTTP 服务器的程序规模小,因此通信速度很快。
灵活:HTTP 协议对数据对象没有要求,允许传输任意类型的数据对象,对于正在传输的数据类型,HTTP 协议将通过报头中的 Content-Type 属性加以标记。
无连接:每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。HTTP 协议采用这种方式可以大大节省传输时间,提高传输效率。
无状态:HTTP 协议自身不对请求和响应之间的通信状态进行保存,每个请求都是独立的,这是为了让 HTTP 能更快地处理大量事务,确保协议的可伸缩性而特意设计的。
微信搜索工种耗:灵风的架构笔记,回复:666 领取资料 。
说明一下:
随着 HTTP 的普及,文档中包含大量图片的情况多了起来,每次请求都要断开连接,无疑增加了通信量的开销,因此 HTTP1.1 支持了长连接 Keey-Alive,就是任意一端只要没有明确提出断开连接,则保持连接状态。(当前项目实现的是 1.0 版本的 HTTP 服务器,因此不涉及长连接)
HTTP 无状态的特点无疑可以减少服务器内存资源的消耗,但是问题也是显而易见的。比如某个网站需要登录后才能访问,由于无状态的特点,那么每次跳转页面的时候都需要重新登录。为了解决无状态的问题,于是引入了 Cookie 技术,通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态,同时为了保护用户数据的安全,又引入了 Session 技术,因此现在主流的 HTTP 服务器都是通过 Cookie+Session 的方式来控制客户端的状态的。
URL 格式
URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
一个 URL 大致由如下几部分构成:
简单说明:
http://表示的是协议名称,表示请求时需要使用的协议,通常使用的是 HTTP 协议或安全协议 HTTPS。
user:pass 表示的是登录认证信息,包括登录用户的用户名和密码。(可省略)
www.example.jp 表示的是服务器地址,通常以域名的形式表示。
80 表示的是服务器的端口号。(可省略)
/dir/index.html 表示的是要访问的资源所在的路径(/表示的是 web 根目录)。
uid=1 表示的是请求时通过 URL 传递的参数,这些参数以键值对的形式通过 &符号分隔开。(可省略)
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 三者的关系如下:
URI 有绝对和相对之分:
绝对的 URI:对标识符出现的环境没有依赖,比如 URL 就是一种绝对的 URI,同一个 URL 无论出现在什么地方都能唯一标识同一个资源。
相对的 URI:对标识符出现的环境有依赖,比如 HTTP 请求行中的请求资源路径就是一种相对的 URI,这个资源路径出现在不同的主机上标识的就是不同的资源。
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 方法通过请求正文传参,一般参数长度没有限制。微信搜索工种耗:灵风的架构笔记,回复:666 领取资料 。
HTTP 的状态码
HTTP 常见的 Header
Content-Type:数据类型(text/html 等)。
Content-Length:正文的长度。
Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
User-Agent:声明用户的操作系统和浏览器的版本信息。
Referer:当前页面是哪个页面跳转过来的。
Location:搭配 3XX 状态码使用,告诉客户端接下来要去哪里访问。
Cookie:用户在客户端存储少量信息,通常用于实现会话(session)的功能。
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 程序一定需要调用 exec 系列函数进行进程程序替换,但服务器创建的新线程与服务器进程使用的是同一个进程地址空间,如果直接让新线程调用 exec 系列函数进行进程程序替换,此时服务器进程的代码和数据就会直接被替换掉,相当于 HTTP 服务器在执行一次 CGI 程序后就直接退出了,这肯定是不合理的。因此新线程需要先调用 fork 函数创建子进程,然后让子进程调用 exec 系列函数进行进程程序替换。
二、完成管道通信信道的建立
调用 CGI 程序的目的是为了让其进行数据处理,因此我们需要通过某种方式将数据交给 CGI 程序,并且还要能够获取到 CGI 程序处理数据后的结果,也就是需要进行进程间通信。因为这里的服务器进程和 CGI 进程是父子进程,因此优先选择使用匿名管道。
由于父进程不仅需要将数据交给子进程,还需要从子进程那里获取数据处理的结果,而管道是半双工通信的,为了实现双向通信于是需要借助两个匿名管道,因此在创建调用 fork 子进程之前需要先创建两个匿名管道,在创建子进程后还需要父子进程分别关闭两个管道对应的读写端。
三、完成重定向相关的设置
创建用于父子进程间通信的两个匿名管道时,父子进程都是各自用两个变量来记录管道对应读写端的文件描述符的,但是对于子进程来说,当子进程调用 exec 系列函数进行程序替换后,子进程的代码和数据就被替换成了目标 CGI 程序的代码和数据,这也就意味着被替换后的 CGI 程序无法得知管道对应的读写端,这样父子进程之间也就无法进行通信了。
需要注意的是,进程程序替换只替换对应进程的代码和数据,而对于进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的。因此子进程进行进程程序替换后,底层创建的两个匿名管道仍然存在,只不过被替换后的 CGI 程序不知道这两个管道对应的文件描述符罢了。
这时我们可以做一个约定:被替换后的 CGI 程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据。这样一来,所有的 CGI 程序都不需要得知管道对应的文件描述符了,当需要读取数据时直接从标准输入中进行读取,而数据处理的结果就直接写入标准输出就行了。
当然,这个约定并不是你说有就有的,要实现这个约定需要在子进程被替换之前进行重定向,将 0 号文件描述符重定向到对应管道的读端,将 1 号文件描述符重定向到对应管道的写端。
四、父子进程交付数据 这时父子进程已经能够通过两个匿名管道进行通信了,接下来就应该讨论父进程如何将数据交给 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 机制的意义
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 程序写入标准输出的数据最终就能输出到浏览器。
日志编写
服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。本项目中的日志格式如下:
日志说明:
日志级别:分为四个等级,从低到高依次是 INFO、WARNING、ERROR、FATAL。
时间戳:事件产生的时间。
日志信息:事件产生的日志信息。
错误文件名称:事件在哪一个文件产生。
行数:事件在对应文件的哪一行产生。日志级别说明:
INFO:表示正常的日志输出,一切按预期运行。
WARNING:表示警告,该事件不影响服务器运行,但存在风险。
ERROR:表示发生了某种错误,但该事件不影响服务器继续运行。
FATAL:表示发生了致命的错误,该事件将导致服务器停止运行。
日志函数编写 我们可以针对日志编写一个输出日志的 Log 函数,该函数的参数就包括日志级别、日志信息、错误文件名称、错误的行数。如下:
说明一下:调用 time 函数时传入 nullptr 即可获取当前的时间戳,因此调用 Log 函数时不必传入时间戳。
文件名称和行数的问题
通过 C 语言中的预定义符号__FILE__和__LINE__,分别可以获取当前文件的名称和当前的行数,但最好在调用 Log 函数时不用调用者显示的传入__FILE__和__LINE__,因为每次调用 Log 函数时传入的这两个参数都是固定的。
需要注意的是,不能将__FILE__和__LINE__设置为参数的缺省值,因为这样每次获取到的都是 Log 函数所在的文件名称和所在的行数。而宏可以在预处理期间将代码插入到目标地点,因此我们可以定义如下宏:
后续需要打印日志的时候就直接调用 LOG,调用时只需要传入日志级别和日志信息,在预处理期间__FILE__和__LINE__就会被插入到目标地点,这时就能获取到日志产生的文件名称和对应的行数了。
日志级别传入问题 我们后续调用 LOG 传入日志级别时,肯定希望以 INFO、WARNING 这样的方式传入,而不是以"INFO"、"WARNING"这样的形式传入,这时我们可以将这四个日志级别定义为宏,然后通过 #将宏参数 level 变成对应的字符串。如下:
此时以 INFO、WARNING 的方式传入 LOG 的宏参数,就会被转换成对应的字符串传递给 Log 函数的 level 参数,后续我们就可以以如下方式输出日志了:
套接字相关代码编写
们可以将套接字相关的代码封装到 TcpServer 类中,在初始化 TcpServer 对象时完成套接字的创建、绑定和监听动作,并向外提供一个 Sock 接口用于获取监听套接字。
此外,可以将 TcpServer 设置成单例模式:
将 TcpServer 类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。提供一个指向单例对象的 static 指针,并在类外将其初始化为 nullptr。提供一个全局访问点获取单例对象,在单例对象第一次被获取的时候就创建这个单例对象并进行初始化。代码如下:
说明一下:
如果使用的是云服务器,那么在设置服务器的 IP 地址时,不需要显式绑定 IP 地址,直接将 IP 地址设置为 INADDR_ANY 即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于 INADDR_ANY 本质就是 0,因此在设置时不需要进行网络字节序列的转换。
在第一次调用 GetInstance 获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以 PTHREAD_MUTEX_INITIALIZER 的方式定义的静态的锁是不需要释放的,同时为了保证后续调用 GetInstance 获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。
HTTP 服务器主体逻辑
我们可以将 HTTP 服务器封装成一个 HttpServer 类,在构造 HttpServer 对象时传入一个端口号,之后就可以调用 Loop 让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象 TcpServer 中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。
代码如下:
说明一下:
服务器需要将新连接对应的套接字作为参数传递给新线程,为了避免该套接字在新线程读取之前被下一次获取到的套接字覆盖,因此在传递套接字时最好重新 new 一块空间来存储套接字的值。
新线程创建后可以将新线程分离,分离后主线程继续获取新连接,而新线程则处理新连接发来的 HTTP 请求,代码中的 HandlerRequest 函数就是新线程处理新连接时需要执行的回调函数。
运行服务器时要求指定服务器的端口号,我们用这个端口号创建一个 HttpServer 对象,然后调用 Loop 函数运行服务器,此时服务器就会不断获取新连接并创建新线程来处理连接。
HTTP 请求结构设计
我们可以将 HTTP 请求封装成一个类,这个类当中包括 HTTP 请求的内容、HTTP 请求的解析结果以及是否需要使用 CGI 模式的标志位。后续处理请求时就可以定义一个 HTTP 请求类,读取到的 HTTP 请求的数据就存储在这个类当中,解析 HTTP 请求后得到的数据也存储在这个类当中。
代码如下:
HTTP 响应结构设计
HTTP 响应也可以封装成一个类,这个类当中包括 HTTP 响应的内容以及构建 HTTP 响应所需要的数据。后续构建响应时就可以定义一个 HTTP 响应类,构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。
代码如下:
评论