项目:基于自主web服务器的在线大整数乘法运算

一、项目背景

    http协议被广泛使用,从移动端到pc端浏览器,http协议无疑是打开互联网应用窗口的重要的协议,http在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。做这个项目可以进一步加深对http协议的理解,并且帮助我理解常见互联网应用行为,从零开始完成web服务器的开发,进一步熟悉网络编程。

二:项目准备

1、项目实现目标
  (1) 从零开始完成web服务器开发
  (2) 基于所开发的web服务器实现在线大整数乘法运算功能
2、开发环境
  (1) CentOS 7
  (2) vim/g++
  (3) C++
3、项目涉及知识点
  (1) 网络编程(基于TCP协议、http协议)
  (2) 多线程技术
  (3) cgi技术
  (4) 进程间通信
  (5) 文件描述符重定向
4、项目框架
在这里插入图片描述

5、代码框架
  (1) 服务器封装模块:HttpServer.hpp
  (2) http请求、响应模块:Protocol.hpp
  (3) 工具模块:Util.hpp
  (4) 日志模块 Log.hpp
  (5) 套接字封装模块:Sock.hpp
  (6) 线程池封装模块:ThreadPool.hpp
  (7) 系统驱动模块:main.cc
  (8) 网页内容模块:wwwroot

三:项目设计

1、套接字封装模块 (Sock.hpp)
    这个模块将服务器建立连接需要的套接字相关函数封装成一个Sock类,封装的函数有套接字创建(socket)、套接字绑定(bind)、套接字监听(listen)、接受连接(accept)。将这些函数封装为了在写服务器程序时方便调用。
2、服务器封装模块 (HttpServer.hpp)
    服务器封装模块将服务器启动所需要的一系列动作封装成了一个类HttpServer。
  (1) 创建服务器对象时的构造:将port、listen_sock、tp(线程池指针)初始化为默认值。
  (2) 对服务器初始化( InitServer() ):创建套接字 、给套接字绑定端口号、将套接字设置为监听到来的连接请求的状态、将SIGPIPE信号忽略、创建线程池、初始化线程池。
  (3) 启动服务器( Start() ):这个函数中有一个for(;;)循环,用来让服务器循环接受( Accept() )连接,每次成功接受一个连接后,在for(;;)循环中会设置一个任务(任务中关键的信息是Accept后返回的文件描述符和处理任务的函数的指针),再将这个任务放到任务队列中,等待线程池中的空闲线程去处理任务。
忽略SIGPIPE信号的运原因: 当客户端关闭时,服务器再往客户端写,会获得SIGPIPE信号,这个信号的默认操作就是退出进程,这样服务器进程就被退出了,所以需要我们忽略掉。
参考博客:https://blog.csdn.net/yockie/article/details/52176171
3、工具模块 (Util.hpp)
    工具模块封装了一个Util类,这个类中封装了在项目中会用到的一些方法。StringToInt方法:将字符串转换为整数;IntToString方法:将整数转换为字符串;SuffixToType方法:根据文件的后缀得到响应报文中Content-Type所对应的值;MakeKV方法:从一个键值对字符串中获取键和值然后组成键值对,它用到的方法就是在传入的string对象中找": "的位置,然后根据这个位置将键和值分离出来;GetStatusLine方法:根据状态码确定响应报文的状态行。
4、日志模块 (Log.hpp)

#define LOG(ERR_LEVEL, MESSAGE) Log(#ERR_LEVEL, MESSAGE, __FILE__, __LINE__)  

    日志模块主要的作用就是帮程序员跟踪程序的状态,我设计的日志模块所关心的内容有:错误级别、要打印的消息(正常情况或者不正常情况)、发生错误时的时间戳、程序的文件名、程序发生错误处的行号。
5、线程池封装模块 (ThreadPool.hpp)
    这个模块包含两个类,线程池封装类ThreadPool,任务类Task。Task类中封装了设置任务函数(SetTask() )和任务启动函数(Run() ),以及accept返回的sock和任务处理函数的地址h。在ThreadPool类中,封装了对线程池的基本操作,该类中包含4个成员变量,num指定线程池线程数量,q是一个任务队列,lock是互斥锁,用来保证对任务队列操作的原子性,cond是一个条件变量,用来保证生产者和消费者之间的同步关系。InitThreadPool()函数初始化线程池,创建num个线程,并且将线程分离。Routine()函数是初始化线程时赋给线程的执行函数,函数中对任务队列进行操作,在合适的时候取出任务,然后执行任务。PushTask()PopTask()分别是任务入队列和任务出队列操作。
为什么要使用线程池技术: 线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个 线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不 仅能够保证内核的充分利用,还能防止过分调度。
    1、需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使 用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。
    2、对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
    3、接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没 有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程 可能使内存到达极限,出现错误。
线程池的使用:
    1、创建固定数量线程的线程池,从任务队列中循环获取任务对象;
    2、获取到任务对象后,执行任务对象中的任务接口。(task->Run())
6、http请求、响应模块 (Protocol.hpp)
    这个模块内容比较多,主要是对收到的请求进行处理和根据请求产生响应的一系列方法,包含了HttpReauest类、HttpResponse类、Connect类、Entry类。
(1) HttpReauest类封装了以下成员变量:
在这里插入图片描述
它首先可以在程序解析请时将解析结果(请求行、请求头等)记录到HttpReauest类对象的成员变量中,程序解析请求报文的方法在Connect类中。而且在初始化对象时会把path成员变量初始化为WWWROOT,也就是#define WWWROOT "./wwwroot",将请求路径低位到wwwroot目录下。这个类中封装了一些其他方法,用来进一步处理从请求报文中解析出来的内容。
  RequestLineParse()是对请求行的解析,解析方法就是借助一个stringstream类对象达到分割字符串的效果,把request_line按空格分割依次输出到method、url、version成员变量中达到对请求行解析的效果。
  RequestHeaderParse()是对请求报头的解析,解析方法是用find()方法从request_header中找\n,从每次找出来的一行内容中调用Util::MakeKV()方法获得报头中的键值对。
  OpenResources()函数用来在非CGI情况下打开请求的资源文件,并且得到打开的文件对应的文件描述符。
  IsMethodOk()函数用来判断请求方法是否正确,这个函数中用了strcasecmp()方法,进行忽略大小写的比较,判断请求方法是不是GET或者POST。
  PathIsLegal()函数先用stat(path.c_str(), &st) == 0判断请求的资源路径是否存在,这个函数会获得参数1对应文件相关信息到参数2中,如果成功返回0,失败返回-1。如果存在再判断请求的资源是目录还是可执行文件或者是普通文件。通过S_ISDIR(st.st_mode)判断如果是目录且最后一个字符是/,则需要在请求路径path后面加一个WELCOME_PAGE让服务器响应首页信息;如果是目录且最后一个字符不是/,则需要在请求路径path后面加/和WELCOME_PAGE让服务器响应首页信息。要特别注意,当加完WELCOME_PAGE后需要用stat(path.c_str(), &st);重新获得文件相关信息,因为加完文件已经变了,相关属性也会变。通过(st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH)判断如果是可执行文件,说明需要cgi处理,cgi = true;。如果是普通文件,则无需做任何操作。最后,对于响应首页信息的情况,从st中获得要响应的页面的大小,并且从请求路径中获取所请求的文件的后缀suffix。
  UrlParse()是对GET方法的url解析,如果url中有参数,需要将url解析字符串分割,path得到路径,query_string得到参数。否则只需path得到路径就可以了。
  SetUrlToPath()是对POST方法的path数字,因为POST方法url中本身没有参数,所以不用字符串分割,直接得到路径。
(2) HttpResponse类包含下面的成员变量:
在这里插入图片描述
这个类封装的方法主要是在生成响应报文的过程中调用相应方法为响应报文各个部分变量赋值。
(3) Connect类主要是对accept成功后反回的文件描述符sock进行的一系列操作。
  RecvLine()方法从sock中读取一行内容,这里需要注意的是要考虑每行内容四以什么结尾的(\r、\n、\r\n),所以如果我们先读到的是\r的话需要先窥探一下下一位,如果下一位是\n,说明是以\r\n结尾,再读一位,否则说明是以\r结尾,不用再读了,不管是以什么结尾,最后我们都是将其处理成以\n结尾。
  RecvHttpRequestLine()函数只是调用了RecvLine()函数读取请求的第一行。
  RecvHttpRequestHeader()函数通过封装RecvLine(),读取请求头,一直读到空行位置。
  RecvHttpBody()函数根据content_length来读取请求报文体的内容,只有POST方法有body,所以这个函数最后要告诉程序以cgi的方式运行。
  SendResponse()函数把响应发到sock上。对于响应报文体,如果是cgi处理的情况,需要发送cgi处理后的结果response_text;如果不是cgi处理的情况,需要发送请求的文件内容。
(4) Entry类,
  MakeResponse()函数用来生成响应,包括状态行、响应报文头。如果是cgi运行方式,报文体大小是response_text的大小;否则报文体大小是打开的文件的大小并且打开文件。
  ProcessCGI()函数处理cgi情况,这个函数主要用到了进程间通信的知识。在这里我们需要创建一个读管道read_pipe和一个写管道read_pipe,并且管道的读写是站在子进程的角度看待的。我们创建一个子进程,在子进程中执行close(read_pipe[1]);close(write_pipe[0]);,然后需要重定向文件描述符dup2(read_pipe[0], 0);dup2(write_pipe[1], 1);,因为在程序替换后会将原来的程序中的变量也替换掉,所以进程原来打开的文件虽然还在,文件对应的文件描述符也没变,但是供程序员操作的记录打开文件的文件描述符的变量read_pipe[0]和write_pipe[1]已经不在了,所以我们就不能对管道文件进行读写了;我们的解决方法就是增加约定,利用重定向技术来完成文件描述符的一定,通过0文件描述符读取,放1文件描述符打印,因为程序替换后并不会把已经打开的文件对应的描述符消除。然后使用execl()函数将子进程用cgi程序替换掉。在子进程中,从0读就是读取管道内容,往标准输出写就是将内容写入管道。在父进程中,将请求中的参数内容写到管道read_pipe[1],然后从write_pipe[0]读取cgi程序处理的结果,读取完后将结果设置到response_text中。对于cgi程序testCgi.cc,先从0中把请求报文的参数读取出来,然后进行处理,最后将处理结果cout出去,因为研究重定向,所以cout到管道中去了。
  HandlerRequest()函数是要传给线程池线程的处理任务函数,这个函数封装了一系列方法,完成如下任务:读取请求报文的内容,解析请求第一行行和请求头,对于post方法获得报文体,对于get方法解析url,对于cgi情况进行cgi处理,对于非cgi情况无需处理,生成响应报文,发送响应。
7、网页内容模块 (wwwroot)
    这个模块中包含了所有 客户端要访问的内容,有网页内容文件,网页布局文件,或者cgi相关的处理文件。在本项目中,我在index.html中写了一个简单的页面用来让客户端输入两个大整数,cgi文件夹下有一个testCgi可执行程序,这个程序从获得的客户端请求中解析出两个大整数(解析方法就是对解析的字符串从左开始找到=&的位置,他们之间的字符串就是第一个大整数,然后对解析的字符串从右开始找到=的位置,从这个位置到最后就是第二个大整数),并且对这两个大整数进行乘法运算(运算方法就是用string类型乘string类型模拟两个整数的乘法过程,结果用string类型表示),将结果写入服务器响应报文中。具体操作见下图:

简单的网页:
在这里插入图片描述

点击Submit后浏览器得到的结果:
在这里插入图片描述
HTTP的CGI机制: CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,有着不可替代的重要地位。CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的过程。其实,要真正理解CGI并不简单,首先我们从现象入手。浏览器除了从服务器下获得资源(网页,图片,文字等),有时候还会上传一些东西(提交表单,注册用户之类的),如果我们的http只能进行获得资源,并不能够进行上传资源,那么我们的http并不具有交互式。为了让我们的网站能够实现交互式,我们需要使用CGI完成。理论上,可以使用任何语言来编写CGI程序。注意,http提供CGI机制,和CGI程序是两码事,就好比学校(http)提供教学(CGI机制)平台,学生(CGI程序)来学习。在我们实现上,要理解CGI,首先得理解GET方法和POST方法的区别:GET方法从浏览器传参数给http服务器时,是需要将参数跟到URI后面的,POST方法从浏览器传参数给http服务器时,是需要将参数放到请求正文的。
GET方法,如果没有传参,http按照一般的方式进行,返回资源即可。
GET方法,如果有参数传入,http就需要按照CGI方式处理参数,并将执行结果(期望资源)返回给浏览器。
POST方法,一般都需要使用CGI方式来进行处理。
用图解释一下我们的HTTP CGI:
在这里插入图片描述
下面三种情况会以cgi的方式运行:
  (1) 带参的get方法
  (2) POST方法
  (3) 请求的资源本身具有可执行权限

8、系统驱动模块 (main.cc)
    这个模块是整个项目的主函数,它用于启动整个项目。主要是创建一个服务器对象new HttpServer(),将服务器初始化svr->InitServer(),启动服务器svr->Start()

四:项目运行流程

  1、运行服务器程序,创建监听套接字,套接字绑定端口,将套接字设置为监听状态,忽略SIGPIPE信号,创建线程池对象,线程池初始化(创建线程并且分离,然后挂起),accept函数等待连接。
  2、客户端(浏览器)访问服务器,三次握手成功后,accept返回接受套接字,生成一个任务,放到任务队列等待处理。
  3、处理任务:从请求报文中把报文首行、报文头分别取出来,然后对报文首行和报文头分别进行解析,判断请求方法是OK的情况下,判断请求方法是GET方法,然后执行解析url的操作,发现url没有带参数,获得访问路径并且告诉程序后面将执行非cgi过程。判断路径是否合法,在这个过程中,会在路径中加入index.html,也就是我自己写的简单页面。根据路径打开文件,得到文件描述符。然后生成响应报文前半部分,包括状态行(HTTP/1.0 200 OK)、响应头(Content-Type、Content-Length)、空行。先发送响应报文前半部分,然后发送响应体,也就是index.html文件sendfile(sock, rq->GetFd(), nullptr, rq->GetFileSize()),发送完毕关闭连接。
  4、浏览器收到响应报文,解析报文得到页面,关闭连接,然后输入两个大整数,点击提交,再次向服务器发起请求。三次握手成功后,accept返回接受套接字,生成一个任务,放到任务队列等待处理。
  5、处理任务:从请求报文中把报文首行、报文头分别取出来,然后对报文首行和报文头分别进行解析,判断请求方法是OK的情况下,判断请求方法是POST方法,对报文体进行获取(获取参数),获取请求路径(cgi程序的位置)。分析路径是否合法,发现是可执行程序,告诉程序接下来将用cgi的方式处理。以cgi方式处理,父进程负责将参数写入管道,子进程从管道读取参数,cgi程序处理参数,子进程将处理结果写入管道,父进程从管道读取处理结果,将结果放到response_text中。生成响应报文前半部分,包括状态行(HTTP/1.0 200 OK)、响应头(Content-Type、Content-Length)、空行。最后先发送生成的响应报文前半部分(状态行、响应头、空行),然后发送响应体(response_text),发送完毕关闭连接。
  6、浏览器收到响应报文,解析报文得到报文体中的处理结果,在页面上显示结果,关闭连接。

五:项目开发中遇到的问题

  (1) 当客户端关闭的时候服务器会挂掉:当我用浏览器请求服务器的时候,如果在请求过程中浏览器点了叉号关闭客户端,那么我的服务器就之间挂掉了。经过研究我发现原来是因为当客户端关闭的时候,如果服务器继续写进程就会收到SIGPIPE信号,而这个信号默认的处理动作就是结束进程,所以服务器挂了。解决方法就是在初始化服务器的时候将SIGPIPE信号忽略掉signal(SIGPIPE, SIG_IGN);,这样一个客户端的关闭就不会影响整个服务器挂掉了。
  (2) 在访问量大的是后会出现recv request error!死循环:在小访问量的时候问题不大,但是如果访问量大的时候就会出现该问题,经过研究我发现是因为我没有进行差错处理。原因主要在RecvLine()函数上,这个函数如果读取出错的话,那就永远不会 读到空行了"\n",所以在RecvHttpRequestHeader()的循环中就永远不会退出循环,具体解决办法如下:
在这里插入图片描述
设置一个标记位result,当读取行出错时,将result设置为false,用于后面的判断。
在这里插入图片描述
通过判断发现读取行出错,直接跳出循环,避免死循环。
为了程序的正常运行,还有一些其他的地方做了类似的处理,思想都是一样的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值