HTTP服务器
- 【项目】HTTP服务器
- 项目介绍
- 背景
- 项目描述
- 技术特点
- 开发环境
- 网络协议栈
- HTTP协议
- 特点
- URI & URL & URN
- URL格式
- HTTP请求与响应
- 请求
- 响应
- CGI机制
- CGI的实现
- CGI的意义
- 日志
- 封装TcpServer类
- 线程池
- 任务类
- CallBack回调方法类
- 线程池类
- 封装HttpServer类
- 主函数
- 封装HTTP请求类
- 封装HTTP响应类
- 封装处理请求类—EndPoint
- EndPoint整体框架
- 差错处理--读取错误
- 完善CallBack
- EndPoint—RecvHttpRequest
- 读取请求行----RecvRequestLine
- 读取请求报头----RecvRequestHeader
- 解析请求行----ParseHttpRequestLine
- 解析请求报头----ParseHttpRequestHeader
- 读取请求正文----RecvHttpRequestBody
- EndPoint—HandlerHttpRequest & EndPoint—BuildHttpResponse
- EndPoint—SendHttpResponse
【项目】HTTP服务器
项目介绍
背景
http协议被广泛使用,从移动端,pc端浏览器,http协议无疑是打开互联网应用窗口的重要协议,http在网络应用层 中的地位不可撼动,是能准确区分前后台的重要协议。
项目描述
采用C/S模型,编写支持中小型应用的简易HTTP服务器,通过基本的网络套接字读取客户端发送的HTTP请求,根据请求构建HTTP相应并返回给客户端。
技术特点
- 网络编程(TCP/IP协议, socket流式套接字,http协议)
- 多线程技术
- cgi技术
- shell脚本
- 线程池
开发环境
- centos 7
- vim/gcc/gdb
- C/C++
网络协议栈
HTTP分层概览
各层功能
- 链路层:负责数据的真正发生过程,也是数据从你的主机经过多次跳跃到达目标主机的过程
- 网络层:解决定位目标主机的问题,相当于快递单上的收件地址
- 传输层:保证数据的可靠性,确保数据成功到达对端,处理传输时遇到的问题
- 应用层:解决数据到达后如何处理的问题,达到某种业务目的
细节展示
约定好的协议需要在每一层都被添加上,任何一台主机发送数据给另一台主机时,必须要经过网络协议栈自上而下的包装,添加上每一层协议的报头信息,才能被遵循同一协议约定的对方主机自下而上的解包提取并成功接收发送来的数据。
HTTP协议
HTTP(超文本传输协议)是基于TCP的连接方式进行网络连接
特点
- 客户/服务器模式(B/S,C/S)
- 简单快速,HTTP服务器的程序规模小,因而通信速度很快。
- 灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
- 无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用 这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)
- 无状态
注意:
http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。
可是,随着web的发展,因为无状态而导致业务处理变的棘手起来。比如保持用户的登陆状态。 http/1.1虽然也是无状态的协议,但是为了保持状态的功能,引入了cookie和session技术来保存和维护用户的状态信息
URI & URL & URN
-
URI,是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源
-
URL,是uniform resource locator,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
-
URN,uniform resource name,统一资源命名,是通过名字来标识资源,比如mailto:java-net@java.sun.com。
URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。
URL和URN都是一 种URI. URL是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI 。如果这个标识是一个可获取到上述对象的路径,那么同时它也可以是一个 URL ;但如果这个标识不提供获取到对象的路径,那么它就必然不是 URL
eg: URI: /home/index.html ----没有提供路径
URL: www.xxx.com:/home/index.html ----提供了路径
URL格式
- **http://:**协议名称,表示请求时使用的协议,通常有两种:HTTP协议或者安全协议HTTPS
- user:pass: 登录认证信息,负载登录用户的用户名和密码(可省略)
- www.example.jp: 表示服务器地址,可以直接使用ip地址,但DNS技术的引入一般都以域名表示
- 80:服务器的端口号----大部分URL的端口号是省略的,因为规定常见协议的端口号是固定的,例如HTTP-80,HTTPS-443,SSH-22等等
- /dir/index.html:表示要访问资源所在的路径(其中第一个" / "代表web根目录)----一般每个目录下都要设置首页,若访问服务器时输入的URL没有指定要访问的资源路径:如www.example.jp:80 ,那么浏览器会自动添加web根目录 " / ",其次自动将路径设置为主页所在路径,后续会讲到
- uid=1:表示请求时通过URL传递的参数,这些参数以键值对的形式通过&符号分隔开(根据请求反法可省略)
- ch1:片段标识符,是对资源的部分补充(可省略)
HTTP请求与响应
请求
请求由四个部分构成
- 请求行:请求方法Method + URI + HTTP版本
- 请求报头:请求的属性,以key:value的形式按行陈列
- 空行:分隔符,遇到连续两个换行符,既遇到空行则代表请求报头结束
- 请求正文:允许是空字符串,若请求正文存在,则在请求报头中会有对应正文长度Content-Length属性
请求方法Method
最常见最常用的就是GET和POST方法:
- GET一般用于获取某种资源信息
- POST一般用于将数据上传服务器
- GET能做到的POST也能做到,只不过方式不同,比如将数据上传服务器,若使用的GET方法,则通过URL传参,而POST方法通过请求正文传参。只不过用GET方法传参由于URL长度有限制,因此参数不能太长,而POST方法传参没有长度限制,因此更常用
响应
- 状态行:HTTP版本 + 状态码 + 状态码描述
- 响应报头:响应的属性,以key:value的形式按行陈列
- 空行:分隔符,遇到连续两个换行符,既遇到空行则代表请求报头结束
- 响应正文:允许是空字符串,若响应正文存在,则在响应报头中会有对应正文长度Content-Length属性
状态码
HTTP状态码(HTTP Status Code)是用以表示服务器HTTP响应状态的3位数字代码。通过状态码,就可以知道服务 器端是否正确的处理的请求,如果不正确,是因为什么原因导致的(404)
- 200 OK : 客户端发来的http请求,被正确处理了
- 204 No Content: 表明请求结果被正确处理了,但是响应信息中没有响应正文
- 206 Partial Content :该状态码表示客户端对服务器进行了范围请求,而且服务器成功的执行了这部分GET请求,响应报文中包含由Content-Range指定的实体内容范围。
- 301 Moved Permanently 永久性重定向:该状态码表示请求的资源已经被分配了新的URI,以后应使用新的URI,也就是说,如果之前将老的URI保存为书签了,后面应该按照响应的Location首部字段重新保存书签
- 302 Found : 临时性重定向
- 307 Temporary Redirec: 临时重定向
- 400 Bad Request : 该状态码表明请求报文中存在语法错误,需修改请求内容重新发送,另外,浏览器会像200 OK一样对待该状态码。
- 403 Forbidden :该状态码表明浏览器所请求的资源被服务器拒绝了。服务器没有必要给出详细理由,如果想要说明,可以在响应实体内部进行说明。
- 404 Not Found: 所请求的资源不存在
- 500 Internal Server Error: 表明服务器端在执行的时候发生了错误,可能是Web本身存在的bug或者临时故障
- 503 Server Unavailable: 该状态码表明服务器目前处于超负载或正在进行停机维护状态,目前无法请求处理。这种情况下,最好写入Retry-After首部字段在返回给客户端
请求与响应正文常见的属性----Header
- Content-Type: 数据类型(text/html等)
- Content-Length: Body的长度
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能;
CGI机制
CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,有着不可替代的重要地位。CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的过程。通过CGI接口,Web服务器就能够获取客户端提交的信息,转交给服务器端的CGI程序进行处理,最后返回结果给客户端。
实际上,我们进行网络请求时无非就是两种目的:
- 浏览器(客户端)想从服务端上获取某种资源,比如下载音乐,打开视频等
- 浏览器(客户端)想将数据上传给服务器,比如上传音乐,视频,用户注册,登录等
而上传数据时,不管是使用POST方法还是GET方法将数据给服务器,服务器都是要交给相关的程序进行处理,并完成目的。比如用户提交的登录账号和密码最终是通过cgi程序录入到服务端管理的数据库中。
这样来看,其实对数据的处理与HTTP并没有多大关系,取决于业务逻辑,但HTTP需要提供的是CGI机制,而服务端可以根据业务部署若干个CGI程序,用户上传的数据就可以通过HTTP协议提供的CGI机制将数据交给对应业务的CGI程序进行处理,再将处理结果构建HTTP响应返回给浏览器
需要cgi模式的场景
- GET方法----有传参数就需要
- POST方法一般都要
- 用户直接访问服务器上的可执行程序
CGI的实现
创建子进程替换CGI程序
现在已经知道了,CGI机制是需要服务器将数据交给对应业务的CGI程序执行,那么我们想在一个进程执行的时候,去调用另一个程序,就需要用到exec系列函数进行程序替换,但需要注意的是,我们不能直接将当前的进程替换掉,因为此时的场景一定是服务器进程中获取到了新连接并交给线程去完成网络通信,而在Linux系统中,线程是轻量级进程,若直接将当前线程替换成另一个进程,就会变相的将当前服务器进程整个替换到,造成不可逆后果,因此我们需要创建新的子进程来进行程序替换
建立管道----传输数据
调用CGI的本质目的就是处理客户端发送来的数据,因此问题的研究就转成如何将数据交给CGI程序,并且获取CGI程序传回来的结果,其实本质就是进程间通信,考虑到CGI程序是由子进程进行程序替换执行的,因此此时服务端进程与CGI进程是父子进程的关系,我们优先选用匿名管道。
同时,匿名管道是半双工通信,智能实现一端读取另一端写入,因此想要实现双向通信,我们需要一次性借助两个匿名管道。
重定向的规定
建立完管道后,还有个遗留的问题。由于管道是在创建子进程之前就已经创建好的,此时管道通信对应的文件描述符就被写进了父进程管理的文件描述符表中,而虽然子进程创建出来时对应的进程地址空间是以父进程为模板创建的,但此时子进程的工作是进行程序替换,新进程—CGI进程的代码和数据也就覆盖了原来子进程的代码和数据,也就是说,此时新进程的程序栈帧中并不知道管道的文件描述符是多少,也就无法进行读取和写入。
但是进程的替换只是将新的数据与代码加载进物理内存中,并与页表重新建立映射,而进程控制块并没有被替换,即底层创建的两个匿名管道是依然存在的,只是无法通过当前cgi程序知道对应的文件描述符是多少。
因此,我们约定,在cgi程序中,从标准输入读取等价于从管道读取数据,想标准输出写入等价于向管道写入数据,有了这个约定,我们只需要做好重定向功能,就可以顺利的通过管道与服务端进程交换数据信息。
数据的交换
首先,我们将视角面向CGI程序,此时我们需要拿到通过网络通信传给服务端的数据来进行处理,而当前的业务是已经确定的,我们需要先判断,此数据在服务端的来源是通过客户端发送GET方法请求还是POST方法请求得到的,我们知道,若是通过GET方法传参的数据,那么此数据的长度大小一定不会很大(URL长度有限制),而若是通过POST方法传参的数据,其内容的容量肯定是远大于GET方法,在这种情况下,为了提高CGI机制处理效率(尽量减少IO),我们规定:
- GET方法传参的数据,通过环境变量传给CGI程序(环境变量不受进程替换的影响)
- POST方法传参的数据,通过匿名管道传给CGI程序
所以在一个CGI程序中,需要提取到以下信息:
- 数据来源的传参方式——统一用环境变量提取----getenv
- 根据传参方式用不同的方式提取——GET用环境变量提取,POST用管道读取
- 处理结果用管道写入
那么在服务端进程,就需要做以下工作:
- 先通过putenv的方式将http请求method导入环境变量中
- 根据请求method用不同的方式交付数据——GET用环境变量导入,POST用管道写入
- cgi处理结果数据统一用管道读取
逻辑图:
CGI的意义
- http服务器逻辑与业务逻辑的解耦合----通过cgi,http服务器只需要关心http请求,处理好请求,构建好响应就是他的任务,而至于请求什么,数据有什么用不需要关心,交给cgi处理
- 与客户端(浏览器) ”直接交流“ ---- 若客户端有上传数据,那么这份数据通过一系列操作传到了cgi手中,而根据约定,CGI都是直接从标准输入读取的数据,根本不关心其他,并且处理结果也是交给了标准输出,不关心其他,这份数据最终也会到达客户端,所以,完全可以无视一切中间步骤,间接的看成客户端在和CGI程序直接沟通
日志
我们需要设置一套服务器的日志格式来监控服务器程序运行时的状态:
格式: [日志级别] [时间戳] [日志信息] [错误文件名称] [行数]
-
日志级别:四个等级:
-
INFO——正常输出
-
WARNING——警告输出,有风险但不影响
-
ERROR——发生错误,不影响运行
-
FATAL——致命错误,中断程序,停止运行
-
-
日志信息:想监控的信息,服务器端提供
-
函数编写:
-
#define INFO 0
#define WARNING 1
#define ERROR 2
#define FATAL 3
#define LOG(level,message) Log(#level,message,FILE,LINE)//#->把宏的参数转化为字符串
//[日志级别][时间戳][日志信息][错误文件名称][行数]
void Log(std::string level,std::string message,std::string file_name,int line)
{
std::cout<<“[”<<level<<“]”<<“[”<<time(NULL)<<“]”<<“[”<<message<<“]”<<“[”<<file_name<<“]”<<“[”<<line<<“]”<<std::endl;
}
+ 输出示例:```c++
LOG(INFO,"test");
封装TcpServer类
-
我们采用Socket编程来进行网络通信,并使用Tcp协议来保证传输层传输的可靠性,我们选择将套接字的创建、绑定和监听全部封装进TcpServer类中,并向外提供接口来获取监听到的套接字。
-
将TcpServer设置成单例模式,保证服务器的独立
class TcpServer
{
private:TcpServer(int port): _port(port), _listensock(-1){}TcpServer(const TcpServer &) =delete;TcpServer* operator=(const TcpServer& )=delete;public:static TcpServer* GetInstance(int port){static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;if (svr == nullptr){pthread_mutex_lock(&lock);if(svr == nullptr){svr = new TcpServer(port);svr->InitServer();}pthread_mutex_unlock(&lock);}return svr;}void InitServer(){Socket();Bind();Listen();LOG(INFO, "TcpServer init success");}void Socket(){// AF_INET:IPv4, SOCK_STREAM:TCP_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock < 0){LOG(FATAL, "socket error");exit(1);}// 地址复用int opt = 1;setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(int));LOG(INFO, "socket success");}void Bind(){struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET; // IPv4local.sin_port = htons(_port);local.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY:0.0.0.0接收所有请求,云服务器不能直接绑定公网IP// 绑定地址if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0){LOG(FATAL, "bind error");exit(2);}LOG(INFO, "bind success");}void Listen(){// 监听if (listen(_listensock, BACKLOG) < 0){LOG(FATAL, "listen error");exit(3);}LOG(INFO, "listen success");}int Sock(){return _listensock;}~TcpServer(){if (_listensock > 0){close(_listensock);}}private:int _port;int _listensock;static TcpServer *svr;
};
TcpServer* TcpServer::svr = nullptr;
线程池
Web服务器完成网页请求任务虽然任务小,但是数量是巨大的,一个热门网站考验的不是网页的精美程度,而是服务器能否短时间内处理数量如此庞大的连接。
我们知道,服务器处理连接的逻辑是获取连接后创建新的线程来为当前连接提供服务,服务结束后就会销毁,而在大量连接的情况下,将会存在大量线程,带来调度开销,并且大量的创建与销毁线程,也在一定程度上降低了效率。
因此,为了提高效率,维护整体性能,我们引入线程池来处理大量连接请求。使得一定程度上避免在处理短时间任务时创建与销毁线程的代价。保证内核充分利用,还能防止过分调度。
引入线程池
-
服务器启动时,我们先预先创建一批线程和一个任务队列,每当有新连接到来时,就将此连接封装成任务对象并放入任务队列中
-
线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列中没有任务则进入休眠状态(锁+条件变量完成),当有新任务时再唤醒
任务类
-
任务对象:
- 需要提供无参构造
- 绑定一个回调方法,由于我们希望回调方法与类所在的文件能够分离,做好文件的分类,因此我们将回调方法封装进类CallBack中并提供运算符重载(),使这个类的对象能够像函数一样使用
class Task { private:int sock;CallBack handler; public:Task(){}Task(int _sock):sock(_sock){}void ProcessOn(){handler(sock);}~Task(){} };
CallBack回调方法类
class CallBack
{
public:CallBack() {}void operator()(int sock){HandlerRequest(sock);}void HandlerRequest(int sock){//处理客户端发送的请求,也需要解耦合,待办}~CallBack() {}
};
线程池类
做足了准备工作,就可以封装线程池类了:
- 线程池无疑要设计成单例模式,并在程序启动就初始化,因此设计成饿汉模式:
- 步骤:
- 构造函数私有化,拷贝构造和赋值运算符重载私有化
- 提供指向单例对象的static指针,类外初始化为nullptr
- 提供全局访问点获取单例,第一次被获取时初始化
- 步骤:
- 线程池类应包括:
- 任务队列
- 线程池个数num
- 互斥锁,保证任务队列的线程安全
- 条件变量,将任务队列设计成生产者消费者模型,无任务时等待,有任务时唤醒
- 获取唯一单例对象的指针
#define NUM 6class ThreadPool
{
private://任务队列std::queue<Task> task_queue;//并发访问,需要锁来约束pthread_mutex_t lock;pthread_cond_t cond;//预先加载线程池的个数,可通过传参改,默认6个int num;//构造函数私有ThreadPool(int _num=NUM):num(_num){pthread_mutex_init(&lock,nullptr);pthread_cond_init(&cond,nullptr);}ThreadPool(const ThreadPool& )=delete;ThreadPool* operator=(const ThreadPool&)=delete;static ThreadPool* single_instance;//指向唯一对象指针
public://全局访问点static ThreadPool* GetInstance(){static pthread_mutex_t _mutex=PTHREAD_MUTEX_INITIALIZER;//防止多个线程同时进来,二次判断加锁初始化if(single_instance==nullptr){pthread_mutex_lock(&_mutex); if(single_instance==nullptr){single_instance=new ThreadPool();single_instance->InitThreadPool();}pthread_mutex_unlock(&_mutex);}return single_instance;}//判断任务队列是否为空bool IsTaskQueueEmpty(){return task_queue.empty();}//封装加锁、解锁、等待、唤醒void ThreadWait(){pthread_cond_wait(&cond,&lock);}void ThreadWakeUp(){pthread_cond_signal(&cond);}void Lock(){pthread_mutex_lock(&lock);}void Unlock(){pthread_mutex_unlock(&lock);}//初始化线程池bool InitThreadPool(){//预先加载一批线程for(int i=0;i<num;++i){pthread_t tid;if((pthread_create(&tid,nullptr,ThreadRoutine,this)!=0)){//监控LOG(FATAL,"create thread pool error!");return false;}}return true;}//创建出来的线程绑定的执行例程static void *ThreadRoutine(void *args){ThreadPool* tp=(ThreadPool*)args;while(true){//任务对象,输出型参数Task t;//任务队列拿任务时,有可能http服务正在Push任务,需要加锁访问tp->Lock();//防止伪唤醒while(tp->IsTaskQueueEmpty()){//任务队列为空时,一直等待tp->ThreadWait();}//成功唤醒,获取任务tp->PopTask(t); tp->Unlock();//执行请求t.ProcessOn();}}void PushTask(const Task &task){Lock();task_queue.push(task);Unlock();ThreadWakeUp();}void PopTask(Task &task){task=task_queue.front();task_queue.pop();}~ThreadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}
};
ThreadPool* ThreadPool::single_instance=nullptr;
- 毫无疑问,线程所要执行的任务就是要去任务队列中获取任务,但在这之前,需要判断一下此任务队列是否为空,若为空则要进行等待,为了防止伪唤醒,需要用while来判断任务队列是否为空
封装HttpServer类
现在,我们就可以一步步封装完成HttpServer类了
-
我们预想,在启动服务器时,只需要指明对应的端口号即可启动
-
主要完成的任务:获取TcpServer监听到的套接字进行aceept,若成功了则表明已经成功建立了信道,将accept成功获取的套接字传给封装成任务并加载进任务池中,让线程池逻辑去执行即可。
-
由于主线程中设计到管道通信,而管道通信一旦发生写入错误,系统会发送信号直接将线程终止导致服务器崩溃,因此要提前忽略掉信号
#define PORT 8080 class HttpServer { private:int _port;// 监控服务器状态,默认为false(启动中)bool stop;public:HttpServer(int port=PORT): _port(port),stop(false){}void InitServer(){//若写入发生错误,系统会发送信号直接让服务器崩溃,因此要忽略此信号signal(SIGPIPE,SIG_IGN);}//启动逻辑void Loop(){TcpServer* tsvr=TcpServer::GetInstance(_port);//监控LOG(INFO,"loop begin");while(!stop){//输出型参数,客户端信息会被填上struct sockaddr_in peer;socklen_t peerlen = sizeof(peer);int sock = accept(tsvr->Sock(),(struct sockaddr*)&peer,&peerlen);if(sock<0){continue;}//线程池版本Task task(sock);//将任务Push进线程池ThreadPool::GetInstance()->PushTask(task);}}~HttpServer() {} };
主函数
static void Usage(const char *prog)
{std::cout << "Usage: " << prog << " [port]" << std::endl;
}int main(int argc, char *argv[])
{if(argc != 2){Usage(argv[0]);exit(4);}int port = atoi(argv[1]);//创建HTTP服务器对象std::shared_ptr<HttpServer> server(new HttpServer(port));//忽略信号server->InitServer();//启动服务器server->Loop();return 0;}
现在一切工作准备就绪,根据逻辑,现在只需根据HTTP协议来处理HTTP请求,回到CallBack类,现在只需要处理好HandlerRequest函数即可。
封装HTTP请求类
我们需要根据协议,提取客户端发送过来的Http请求数据,并填好请求的相关信息,我们将其封装成类来管理,相关信息包括:
-
请求内容
-
解析结果
-
CGI模式标志位----判断是否需要CGI
class HttpRequest { public:std::string request_line; //请求行std::vector<std::string> request_headers; //请求报头std::string blank; //空行std::string request_body; //请求正文// 请求报头解析结果std::string method; //请求方法std::string url; //URLstd::string path; //请求资源路径std::string query_string; //url携带参数std::string version; //http版本std::string suffix; // 文件后缀// 请求行解析结果,以k-v形式存储std::unordered_map<std::string, std::string> request_headers_kv;int content_length; // 若是POST方法,则请求的正文中的内容大小int content_size; // 请求的资源的大小bool cgi; // cgi标志位public:HttpRequest() : content_length(0), cgi(false) {}~HttpRequest() {} };
封装HTTP响应类
同样的,构建响应时,相关信息也封装成类:
class HttpResponse
{
public:std::string status_line; //状态行std::vector<std::string> response_headers; //响应报头std::string blank; //空行std::string response_body; //响应正文int status_code; //状态码int fd; //响应文件的文件描述符public:HttpResponse() : blank(LINE_END), status_code(OK), fd(-1) {}~HttpResponse() {}
};
封装处理请求类—EndPoint
EndPoint整体框架
处理请求时,我们需要分步骤来处理:
- 先读取客户端发送来的HTTP请求 ----RecvHttpRequest
- 处理客户端发送来的HTTP请求 ----HandlerHttpRequest
- 根据请求构建HTTP响应 ----BuildHttpResponse
- 将响应发送回客户端 ----SendHttpResponse
由于处理请求也需要分这么多个步骤,为了做到回调函数与处理请求的分离与解耦,我们将处理请求单独处理成一个类EndPoint:
//服务端EndPoint
class EndPoint{private:int sock; //通信的套接字HttpRequest http_request; //HTTP请求HttpResponse http_response; //HTTP响应bool stop; //差错处理public:EndPoint(int sock):sock(sock){}//读取请求void RecvHttpRequest();//处理请求void HandlerHttpRequest();//构建响应void BuildHttpResponse();//发送响应void SendHttpResponse();~EndPoint(){}
};
差错处理–读取错误
由于处理请求的前提是读取到请求,若是请求都读取失败了,就不需要后面的逻辑了,所以我们在EndPoint中加入了判断本次读取是否失败的bool类型stop,若读取请求时失败,只需要将此变量设置为true,上层会检测此变量再决定是否进行解析报头等操作
完善CallBack
这样,我们也将CallBack类中处理请求部分给完善好:
class CallBack
{
public:CallBack() {}void operator()(int sock){HandlerRequest(sock);}void HandlerRequest(int sock){LOG(INFO, "HandlerRequest begin");EndPoint *ep = new EndPoint(sock);//差错处理ep->RecvHttpRquest();if (ep->IsStop()){LOG(WARNING, "Recv Error, Stop Build And Send");}else{LOG(INFO, "Recv No Erro, Begin Build And Send");ep->BuildHttpResponse();ep->SendHttpResponse();}delete ep;close(sock);LOG(INFO, "HandlerRequest end");}~CallBack() {}
};
EndPoint—RecvHttpRequest
读取客户端发送来的HTTP请求
读取Http请求又可分成以下几部分:
- 读取请求行
- 读取请求报头和空行
- 解析请求行
- 解析请求报头
- 读取请求正文
并且同样的,若请求行、请求报头和空行都读取失败了,就没有必要解析了,因此我们还是利用stop,并将读取函数的返回值设置成bool类型,若读取异常再更改stop并返回,为了做到解耦,读取请求逻辑如下:
void RecvHttpRquest(){// 接收请求行、请求报头和空行if (!RecvHttpRequestLine() && !RecvHttpRequestHeader()){// 解析请求行ParseHttpRquestLine();ParseHttpRequestHeaders();// 解析完后需要接收请求体RecvHttpRequestBody();}}
读取请求行----RecvRequestLine
根据HTTP协议,直接在该套接字建立的信道中读取一行的内容即可,但需要注意的是,不同平台下,协议的行分隔符是不一样的,有三种可能: \n 、 \r 、\r\n 。
为了确保能够兼容任意平台下这三种分隔符同时出现,我们可以提供一个工具类,并提供一个工具类函数ReadLine,用来提取一行。而后面有任何工具类函数都能封装在这个类内,做到解耦,分离。
工具类函数——ReadLine
-
先判断当前读的字符是否为 \r \n 中的任意一个,若都不是,则一直往后读
-
若读到了 \n ,则说明分隔符是 \n ,此时将 \n 读完就停止读取
-
若读到了 \r ,需要先“窥探”(只看不读)下一个是否为 \n ,若是(窥探成功),则说明分隔符为 \r\n ,此时将 \n 读取完后就停止读取;若不是(窥探失败),则说明分隔符是 \r ,手动添加 \n 并读取后停止读取
- 窥探:recv函数的最后一个参数设置为MSG_PEEK,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,并不将数据从TCP接收缓冲区取走
//工具类,提供一些常用的功能:字符编码转换,字符串分割等 class Util { public:static int ReadLine(int sock,std::string &out){//XXXX\r\n//XXXX\r//XXXX\nchar ch='X';while(ch!='\n'){ssize_t ret=recv(sock,&ch,1,0);if(ret>0){ if(ch=='\r'){//如果ch是\r,需要观察后面一个字符是否是\n,如果是\n,则读上来,如果不是\n,则不能读上来//recv选项,能设置成数据窥探功能,即可以读到\n,但不会真正从缓冲区中取走\nrecv(sock,&ch,1,MSG_PEEK);if(ch=='\n'){//窥探成功,即此时的分隔符是:XXXX\r\nrecv(sock,&ch,1,0);//读到\n}else{//窥探失败,即此时分隔符是:XXXX\rch='\n';}}out+=ch;}else if(ret==0){return 0;}else{return -1;}}return out.size();}
读取请求行函数----RecvRequestLine
bool RecvHttpRequestLine(){if (Util::ReadLine(_sock, _request.request_line) > 0){_request.request_line.resize(_request.request_line.size() - 1);LOG(INFO, _request.request_line);}else{stop = true;}return stop;}
读取请求报头----RecvRequestHeader
根据协议,请求报头和空行也是按行陈列,直接循环调用ReadLine读取到空行:
bool RecvHttpRequestHeader(){std::string line;while (true){//读取前先清空line.clear();if (Util::ReadLine(_sock, line) <= 0){stop = true;break;}if (line == "\n"){_request.blank = line;break;}//先去掉\n再放入请求类中line.resize(line.size() - 1);_request.request_headers.push_back(line);//LOG(INFO, line); ----监控}return stop;}
解析请求行----ParseHttpRequestLine
根据协议,请求行包括:
- 请求方法METHOD----统一全部转化为大写
- URL
- HTTP版本
解析请求行就是将这些信息提取出来并放入请求类中。
void ParseHttpRquestLine(){// 解析方法:stringstreamstd::stringstream ss(_request.request_line);ss >> _request.method >> _request.url >> _request.version;// 进行大小写转化,保证方法都是大写:GET,POSTstd::transform(_request.method.begin(), _request.method.end(), _request.method.begin(), ::toupper);}
解析请求报头----ParseHttpRequestHeader
请求报头描述了当前请求的属性信息,以":"为分隔符隔开,为了保证每个属性信息能够找到其描述内容,我们使用key:value的形式存池,但由于我们读取是是一行一行读取的,因此我们还需要提供一个工具类函数CutString来进行切割当前读取的一行字符串:
工具类函数——CutString
class Util
{
public:static bool CutString(const std::string &target,std::string &sub1_out,std::string &sub2_out,std::string sep){size_t pos=target.find(sep);if(pos!=std::string::npos){sub1_out=target.substr(0,pos);sub2_out=target.substr(pos+sep.size());return true;}return false;}
};
解析请求报头函数——ParseHttpRequestHeader
#define SEP ": "
void ParseHttpRequestHeaders(){for (auto &line : _request.request_headers){std::string key;std::string value;if (Util::CutString(line, key, value, SEP)){_request.request_headers_kv[key] = value;}}}
读取请求正文----RecvHttpRequestBody
读取请求正文的前提是请求行中的请求方法是POST方法,且还得通过请求报头中的Content-Length属性来获取请求正文的长度来进行读取,因此需要提供两个函数来完成:一个判断是否需要读取请求正文,一个用来读取
bool IsNeedRecvHttpRequestBody(){auto &method = _request.method;if (method == "POST"){auto &header_kv = _request.request_headers_kv;auto it = header_kv.find("Content-Length");if (it != header_kv.end()){_request.content_length = std::stoi(it->second);return true;}}return false;}
bool RecvHttpRequestBody(){if (IsNeedRecvHttpRequestBody()){int content_length = _request.content_length;auto &body = _request.request_body;char ch = 0;while (content_length){ssize_t s = recv(_sock, &ch, 1, 0);if (s > 0){body.push_back(ch);content_length--;}else{stop = true;break;}}}return stop;}
EndPoint—HandlerHttpRequest & EndPoint—BuildHttpResponse
对于处理请求和构建响应部分,我们采用 “边处理请求,边构建响应”的方式统一封装在BuildHttpResponse函数中
准备工作:
一、定义状态码
#define BAD_REQUEST 400
#define OK 200
#define NOT_FOUND 404
#define SERVER_ERROR 500
二、定义web根目录,首页文件和版本信息
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"
#define HTTP_VERSION "HTTP/1.0"
三、规定响应策略
构建响应是需要根据请求来构建的,而响应内容是与状态码有关的,所以我们将其设计成在处理请求的过程中边设置状态码,最后再根据状态码的状态统一来构建响应,提高代码可读性。
四、规定cgi策略
我们根据CGI机制的条件来设置cgi标志位,最后再统一根据cgi标志位来完成业务。
处理客户端发送来的HTTP请求 && 根据请求构建HTTP响应
处理请求步骤:
-
判断请求方法是否合法,若不合法则将状态码设置为BAD_REQUEST,由于请求方法都不合法就没有必要走别的逻辑了,因此直接跳到处理状态码部分
if (_request.method != "GET" && _request.method != "POST"){// 非法请求,构建错误响应LOG(WARNING, "Invalid Request Method");code = BAD_REQUEST;goto END;}
-
若为GET方法,则判断URL是否带参来设置cgi标志位
if (_request.method == "GET"){// 有?分隔符代表有参size_t pos = _request.url.find("?");if (pos != std::string::npos){//分割url并设置cgi标志位Util::CutString(_request.url, _request.path, _request.query_string, "?");_request.cgi = true; // get方法且带参那就使用cgi}else{_request.path = _request.url;}}
-
若为POST方法,则直接处理
else if (_request.method == "POST"){_request.cgi = true;_request.path = _request.url;}
-
此时,不管URL是否带参,我们都将其中的客户端访问路径给提取出来了,只需要对路径进行二次处理即可:
-
首先在路径上拼接web根目录
-
需要判断路径的最后一个字符是否为 “/” ,如果是证明客户端访问的是一个目录,服务端需要默认返回此目录下的首页,因此在路径上也需要加上首页路径
-
此时还需要通过stat函数获取客户端请求资源文件的属性信息,因为客户端有可能访问的是可执行程序,需要设置cgi标志位
-
若路径根本不存在,则将状态码设置为NOT_FOUND并直接跳到处理状态码部分
-
顺便提取该目标文件的后缀,方便后面填充报头属性
// 给path添加web根目录_path = _request.path;_request.path = WEB_ROOT + _path;// 若用户访问根目录,则默认返回根目录下的首页if (_request.path[_request.path.size() - 1] == '/'){_request.path += HOME_PAGE;}// 判断用户访问的路径是否存在:使用stat进行判断,获取属性struct stat st;if (stat(_request.path.c_str(), &st) == 0){// 路径存在,默认每一个目录都有index.html,即规定:只要路径存在,此路径下必有首页if (S_ISDIR(st.st_mode)){// 路径是目录,添加首页,若是目录,格式必为:/a/b/c/d,因此要先添加/,再添加首页_request.path += "/";_request.path += HOME_PAGE;// 到这里证明路径存在且用户访问的是一个目录,因此将目录下的首页返回给用户并将首页的文件属性返回给用户->更新功能stat(_request.path.c_str(), &st);}// 请求的是服务器上的一个可执行程序->需要单独特殊处理的// 若该文件具有使用者,小组成员或其他人的可执行权限,则说明是可执行程序if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH)){// 请求的是可执行程序->cgi_request.cgi = true;}// 获取一下文件大小_request.content_size = st.st_size;}else{// 路径不存在std::string info = _request.path + " Not Found";LOG(WARNING, _request.path + "NOT FOUND");code = NOT_FOUND;goto END;}// path:在path里提取文件后缀found = _request.path.rfind(".");if (found == std::string::npos){_request.suffix = ".html";}else{_request.suffix = _request.path.substr(found);}
-
-
处理cgi程序与非cgi程序
-
此时逻辑基本走完,需要根据cgi标志位处理业务,我们还是对此模块进行解耦,提高可读性:
// 走到这判断一下,请求的是否是cgi程序if (_request.cgi){// 请求的是cgi程序,以cgi的机制来处理请求code = ProcessCgi();}else{// 目标网页合法性校验完毕,一定存在// 构建http响应code = ProcessNonCgi(); // 简单的返回静态网页}
-
处理过程中记得处理状态码
-
-
统一处理状态码—我们还是用一个函数来单独完成这部分功能
END:BuildHttpResponseHelper();
CGI & 非 CGI ——ProcessCgi & ProcessNonCgi
CGI机制的处理逻辑前面已经叙述过了,需要注意以下几点:
-
管道的建立:我们以父进程的视角来创建管道,input管道是读取,outpuut管道是写入,那么子进程就是从input管道写入,从output管道读取,那么创建管道后只需关闭不用的一端即可
-
重定向规定的实现:将子进程的标准输入重定向到output管道中,标准输出重定向到input管道中即可
-
子进程原来指向:
重定向后:
-
环境变量的导入:在程序替换前就需要完成环境变量的导入
-
代码:
int ProcessCgi(){// 父进程数据// 若是GET方法,url携带的参数分离后放在query_string中// 若是POST方法,body中的参数放在request_body中int code = OK;//默认为OK//准备工作auto &method = _request.method;auto &query_string = _request.query_string;auto &body_text = _request.request_body;auto &response_body = _response.response_body;int content_length = _request.content_length;std::string method_env;std::string query_string_env;std::string content_length_env;auto &bin = _request.path;// 进程间通信->两个匿名管道// in、out是父进程的视角,父从in管道中读取进来,从out管道写出去int input[2];int output[2];if (pipe(input) == -1 || pipe(output) == -1){LOG(ERROR, "pipe error");code = SERVER_ERROR;return code;}pid_t pid = fork();if (pid == 0){// 约定->进程替换后不知道input和output,因此写等价于写到标准输出,读等价于从标准输入读->重定向// child->cig// 子向in管道写,从out管道读close(input[0]);close(output[1]);method_env = "METHOD=" + method;putenv((char *)method_env.c_str());if (method == "GET"){query_string_env = "QUERY_STRING=" + query_string;putenv((char *)query_string_env.c_str());}else if (method == "POST"){// post方法是通过主体传参,因此需要传内容长度的环境变量让新进程获取content_length_env = "CONTENT_LENGTH=" + std::to_string(content_length);putenv((char *)content_length_env.c_str());}else{}// 重定向标准输入和输出dup2(input[1], 1);dup2(output[0], 0);//程序替换execl(bin.c_str(), bin.c_str(), NULL);exit(1);}else if (pid < 0){// fork失败LOG(ERROR, "fork error");code = NOT_FOUND;return code;}else{// parent->wait// input相对于父进程是读,output相对于父进程是写close(input[1]);close(output[0]);// 通过管道将数据给子进程if (method == "POST"){// 将body数据写到管道中给子进程读取const char *start = body_text.c_str();int total = 0;int size = 0;while (total < content_length && (size = write(output[1], start + total, body_text.size() - total)) > 0){total += size;}}// 环境变量具有全局属性,可以被子进程继承,且不受进程替换影响// 读取处理结果char ch = 0;while (read(input[0], &ch, 1) > 0){response_body.push_back(ch);}int status = 0;pid_t ret = 0;//根据情况设置状态码ret = waitpid(pid, &status, 0);if (ret == pid){if (WIFEXITED(status)){if (WEXITSTATUS(status) == 0){code = OK;}else{code = BAD_REQUEST;}}else{code = SERVER_ERROR;}}close(input[0]);close(output[1]);}return code;}
非CGI机制处理:
若是非CGI机制,那么只需将客户端获取的文件资源作为响应正文直接发送给客户端即可。所以我们正常的逻辑应该是把文件资源的内容加载进Response中,在调用系统调用将文件内容发送出去,但这样做代价太大了,为了减少IO次数,我们只需使用sendfile函数将此文件通过网络建立的信道发送给客户端即可,这样就做到了高效,因此我们在处理非CGI机制就需要保存被打开文件的fd在response类中可:
int ProcessNonCgi(){_response.fd = open(_request.path.c_str(), O_RDONLY);if (_response.fd > 0){return OK;}// 打开文件失败也返回404;return NOT_FOUND;}
BuildHttpResponseHelper----构建响应
处理请求走到最后处理完毕了之后统一处理当前的状态码,即根据当前状态码来构建对应的响应:
-
对于不同的状态码有不同的处理方式,因此我们也将构建响应分为几个函数完成,做到解耦:
- 构建OK响应
- 处理错误
- …
状态码有很多,只需要对应的状态码提供对应的接口即可,这里只介绍两种:200和404
其中状态码描述的获取,我们提供一个工具类函数接口Code2Desc来转化:
static std::string Code2Desc(int code) {std::string desc;switch (code){case 200:desc = "OK";break;case 404:desc = "Not Found";break;case 400:desc = "BAD_REQUEST";case 500:desc = "SERVER_ERROR";default:break;}return desc; }
规定行分割符为" \r\n "前面有规定过:LINE_END
#define PAGE_404 "404.html" #define PAGE_500 "500.html" #define PAGE_400 "400.html"void BuildHttpResponseHelper(){auto &code = _response.status_code;auto &status_line = _response.status_line;// 根据不同的错误构建不同的响应// 构建状态行status_line += HTTP_VERSION;status_line += " ";status_line += std::to_string(code);status_line += " ";status_line += Code2Desc(code);status_line += LINE_END;std::string path = WEB_ROOT;path += "/";// 构建响应正文switch (code){case OK:BuildOKResponse();break;case NOT_FOUND:path += PAGE_404;// 404->处理404,返回404页面HandlerError(path);break;case SERVER_ERROR:HandlerError(PAGE_500);break;case BAD_REQUEST:HandlerError(PAGE_400);break;default:break;}}
构建正常响应报头: BuildOKResponse
-
需要提供工具类函数将响应报头中的文件后缀信息要求转化为格式—SuffixToDesc
static std::string Suffix2Desc(const std::string &suffix) {static std::unordered_map<std::string, std::string> suffix2desc = {{".html", "text/html"},{".css", "text/css"},{".js", "application/javascript"},{".jpg", "application/x-jpg"},{".png", "image/png"},{".xml", "application/xml"},};auto iter = suffix2desc.find(suffix);if (iter != suffix2desc.end()){return iter->second;}else{return "text/html";//没有找到默认为text/html} }
-
void BuildOKResponse(){std::string line = "Content-Type: ";line += Suffix2Desc(_request.suffix);line += LINE_END;_response.response_headers.push_back(line);line = "Content-Length: ";if (_request.cgi)line += std::to_string(_response.response_body.size()); // POST或者GET带参else{line += std::to_string(_request.content_size); // GET}line += LINE_END;_response.response_headers.push_back(line);}
构建错误响应——
若是处理请求过程中出现错误,code也会被设置为错误码404(举例),服务器就返回对应的错误页面,此时响应正文的内容就是错误页面的内容,类型为text/html。后续也是直接调用sendfile发送,因此只需将错误页面的文件打开并添加好对应的文件描述符:
void HandlerError(std::string page){// 出错了都发送静态网页_request.cgi = false;// 打开404页面_response.fd = open(page.c_str(), O_RDONLY);if (_response.fd > 0){struct stat st;//获取文件属性stat(page.c_str(), &st);_request.content_size = st.st_size;std::string line = "Content-Type: text/html";line += LINE_END;_response.response_headers.push_back(line);line = "Content-Length: ";line += std::to_string(st.st_size);line += LINE_END;_response.response_headers.push_back(line);}}
EndPoint—SendHttpResponse
将响应发送回客户端
- 调用send依次以行为单位发送状态行、响应报头和空行
- 通过判断CGI标志位来决定发送响应正文的方式:
- CGI:正文内容在response_body中
- 非CGI:待发送资源文件或者错误页面等页面文件已经被打开且fd被保存,直接调用sendfile发送即可
void SendHttpResponse(){// 响应构建完毕,发送响应// 报头信息send(_sock, _response.status_line.c_str(), _response.status_line.size(), 0);for (auto &iter : _response.response_headers){send(_sock, iter.c_str(), iter.size(), 0);}send(_sock, _response.blank.c_str(), _response.blank.size(), 0);if (_request.cgi){auto &response_body = _response.response_body;int size = 0;int total = 0;const char *start = response_body.c_str();while (total < response_body.size() && (size = send(_sock, start + total, response_body.size() - total, 0)) > 0){total += size;}}else{sendfile(_sock, _response.fd, nullptr, _request.content_size);}// 发送完需要关闭close(_response.fd);}