http协议被广泛使用,从移动端,pc端浏览器,http协议无疑是打开互联网应用窗口的重要协议,http在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。
本项目实现的是一个简易版http服务器,基本的业务逻辑是:通过网络套接字接收请求,而后分析请求,构建响应,返回响应给客户端。
本项目实现时会将主要核心模块抽离出来,着重于http分析请求和构建请求的过程。
项目中使用到的核心技术:网络套接字编程(socket流式套接字), 单例模式, 线程池,CGI机制, 多线程,HTTP协议, TCP/IP协议, c/s模式
开发环境为: centos 7 + vim/gcc/gdb + C/C++
协议分层
分层情况如下:
网络协议栈自底向上各层的作用:
数据链路层: 负责设备之间的数据帧的传送和识别. 例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作. 有以太网、令牌环网, 无线LAN等标准. 交换机(Switch)工作在数据链路层.
网络层: 负责地址管理和路由选择. 例如在IP协议中, 通过IP地址来标识一台主机, 并通过路由表的方式规划出两台主机之间的数据传输的线路(路由). 路由器(Router)
工作在网路层.
传输层: 负责两台主机之间的数据传输. 如传输控制协议 (TCP)
, 能够确保数据可靠的从源主机发送到目标主机.
应用层: 负责应用程序间沟通,如简单电子邮件传输(SMTP)
、文件传输协议(FTP)
、网络远程访问协议(Telnet)
等. 我们的网络编程主要就是针对应用层
也即是说,发送端给接收端发送HTTP数据时,是需要自顶向下经过网络协议栈完成数据报的封装,而后再经过网络的传播,最后客户端接收到我们发送的数据时,也是需要先自底向上经过网络协议栈完成解包和分用(将报头去掉,将有效载荷交付给上层),然后才拿到发送端发送过来的http数据;
而我们这个项目的编写的就是处于应用层的协议,而网络协议栈中的每一层协议在业务上都是相互解耦的,因为下层协议的屏蔽差异作用,所以每一层协议都认为自己是和对端的同层协议直接进行通信的;所以我们在编写应用层协议时,只需要关注应用层的事务细节即可,并不需要关心我们与远端的连接是怎么建立的,我们的数据是怎么进行中转,最后发送到客户端主机上的。
简介
万维网WWW(World Wide Web)发源于欧洲日内瓦量子物理实验室CERN,正是WWW技术的出现使得因特网得以超乎想象的速度迅猛发展。这项基于TCP/IP的技术在短短的十年时间内迅速成为已经发展了几十年的Internet上的规模最大的信息系统,它的成功归结于它的简单、实用。在WWW的背后有一系列的协议和标准支持它完成如此宏大的工作,这就是Web协议族,其中就包括HTTP超文本传输协议。
在1990年,HTTP就成为WWW的支撑协议。当时由其创始人WWW之父蒂姆·伯纳斯·李(Tim Berners-Lee)提出,随后WWW联盟(WWW Consortium)成立,组织了IETF(Internet Engineering Task Force)小组进一步完善和发布HTTP。 [1]
HTTP是应用层协议,同其他应用层协议一样,是为了实现某一类具体应用的协议,并由某一运行在用户空间的应用程序来实现其功能。HTTP是一种协议规范,这种规范记录在文档上,为真正通过HTTP进行通信的HTTP的实现程序。
HTTP是基于B/S架构进行通信的,而HTTP的服务器端实现程序有httpd、nginx等,其客户端的实现程序主要是Web浏览器,例如Firefox、[Internet Explorer](https://baike.baidu.com/item/Internet Explorer/1537769?fromModule=lemma_inlink)、[Google Chrome](https://baike.baidu.com/item/Google Chrome/5638378?fromModule=lemma_inlink)、Safari、Opera等,此外,客户端的命令行工具还有elink、curl等。Web服务是基于TCP的,因此为了能够随时响应客户端的请求,Web服务器需要监听在80/TCP端口。这样客户端浏览器和Web服务器之间就可以通过HTTP进行通信了。 – 取自百度百科
发展历史
0.9协议是适用于各种数据信息的简洁快速协议,但是远不能满足日益发展的各种应用的需要。0.9协议就是一个交换信息的无序协议,仅仅限于文字。由于无法进行内容的协商,在双发的握手和协议中,并有规定双发的内容是什么,也就是图片是无法显示和处理的。 [3]
到了1.0协议阶段,也就是在1982年,Tim Berners-Lee提出了HTTP/1.0。在此后的不断丰富和发展中,HTTP/1.0成为最重要的面向事务的应用层协议。该协议对每一次请求/响应建立并拆除一次连接。其特点是简单、易于管理,所以它符合了大家的需要,得到了广泛的应用。
在1.0协议中,双方规定了连接方式和连接类型,这已经极大扩展了HTTP的领域,但对于互联网最重要的速度和效率,并没有太多的考虑。毕竟,作为协议的制定者,当时也没有想到HTTP会有那么快的普及速度。 [3]
关于HTTP1.1协议的具体内容可以参考RFC 2616。 [4]
2.0
HTTP2.0的前身是HTTP1.0和HTTP1.1。虽然之前仅仅只有两个版本,但这两个版本所包含的协议规范之庞大,足以让任何一个有经验的工程师为之头疼。网络协议新版本并不会马上取代旧版本。实际上,1.0和1.1在之后很长的一段时间内一直并存,这是由于网络基础设施更新缓慢所决定的 – 取自百度百科
Browser
web浏览器)/S,C/S)http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。 – 这就是http无状态的体现;
说明:
注:http本身是无状态的,保持状态的功能并不是通过http本身实现的
uniform resource identififier
,统一资源标识符,用来唯一的标识一个资源uniform resource locator
,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。uniform resource name
,统一资源命名,是通过名字来标识资源,比如mailto:[email protected]。URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。URL和URN都是一种URI.
URL是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI ;
说明:
http://
:表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。user:pass
:表示的是登录认证信息,包括登录用户的用户名和密码。www.example.jp
: 表示的是服务器地址,通常以域名的形式表示。80
:表示的是服务器的端口号。(可省略)/dir/index.html
:表示的是要访问的资源所在的路径(/表示的是web根目录)。uid=1
:表示的是请求时通过URL传递的参数,这些参数以键值对的形式通过&符号分隔开。ch1
:表示的是片段标识符,是对资源的部分补充。 (可省略)注:
/
并不是我们服务器中的根目录,而是web根目录,web根目录就是我们服务器中的一个普通目录,专门存放于网络服务相关的代码和数据,用户可以自行指定index.html
);例子:
我们平时访问的网站名实际上就是一个url: www.baidu.com;但我们实际中使用的url为什么却比上面简单如此之多呢?–当然是有人为我们做了更多的工作
如图:实际上在输入框输入www.baidu.com时,浏览器会自动为我们添加上协议名和端口号;而后服务端会自动将我们的uri转化成对应的web根目录下的路径,而上面我们最后转化出来的路径就是/index.html --即网站首页
Http请求格式
说明:
key: value
字段Content-Type
存放的信息)http响应报文格式
说明:
Content-Type
存放的信息)方法 | 说明 | 支持的http协议版本 |
---|---|---|
GET | 获取资源 | 1.0,1.1 |
POST | 传输实体文件 | 1.0,1.1 |
PUT | 传输文件 | 1.0,1.1 |
HEAD | 获取报文首部 | 1.0,1.1 |
DELETE | 删除文件 | 1.0,1.1 |
OPTIONS | 询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 要求使用隧道协议连接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.0 |
UNLINE | 断开连接关系 | 1.0 |
GET
: 获取资源,获取被URI标识的资源 ;也可以传输资源,和post区别就是使用uri进行资源的传输POST
: 传输实体主体其中http请求方法最常见的就是GET, POST方法;get常用于获取资源,post常用于上传资源,但get也可以通过uri上传资源;
GET vs Post
状态码分类:
类别 | 原因短语 | |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error (客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error (服务器错误状态码) | 服务器处理请求出错 |
常见状态码
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首部字段在返回给客户端CGI(Common Gateway Interface)
是WWW技术中最重要的技术之一,CGI是"公共网关接口"(Common Gateway Interface)的缩写,是一种标准的协议/接口,定义了Web服务器如何调用应用程序,并将程序的输出返回给Web服务器,客户端能看到Web网页的内容。CGI程序通常用于处理用户提交给Web服务器的请求,生成动态内容,并将其返回给Web服务器和浏览器。示意图:
项目中的应用
以上3种情况都需要web服务器执行对应的操作(调用cgi程序–通常是一个个子进程),而后从cgi程序得到运行结果
一:建立通信匿名管道
父进程构建匿名俩个管道(input,output) ,约定父进程使用output管道对子进程进行写入数据,子进程从output管道中读取父进程写入的数据;父进程从input管道读取子进程执行CGI程序的结果;即父进程只需将input[1] , output[0]关闭即可,而子进程只需将input[0], output[1]关闭即可;
使用匿名管道是因为web服务端的进程和执行CGI的进程具有血缘关系,使用匿名管道进行数据交互简单高效;使用俩个匿名管道是因为匿名管道是半双工的,只允许数据从一端流向另一端,而父子进程之间是有数据交互的,所以需要俩个匿名管道;
示意图:
二: 重定向标准输出,标准输入
因为子进程实现进程替换之后,子进程的代码和数据都会被替换,也即我们匿名管道的数据input[2],output[2]
数据会丢失,也就是说进程替换之后,子进程会不认识匿名管道的fd,但底层的数据结构是不会变的(fd_array, file struct , 页表…),等等;即我们所打开匿名管道还是存在的,只是子进程不知道该匿名管道对应的文件描述符是多少;所以我们可以做出一个规定,子进程向标准输入流中读取数据就是从output管道中读取父进程写入的数据,通过标准输出流输出数据就是从input管道向父进程写入程序运行结果(使用重定向实现即可)
伪代码:
dup2(input[1], 1) dup2(output[0], 0)
三: 父子之间数据交互逻辑
我们在这里也做出约定:
get方法传递的数据是在uri里的,通常都是比较短小,如果使用文件写入的方式通过匿名管道,效率反而不高;所以我们可以选用环境变的方式将数据导入到子进程中去
post方法使用管道传输方式,父子间使用read,write
实现数据交互,且导入数据后,子进程需要知道该数据的大小,才知道应该从套接字读取多少字节进行数据处理;所以使用管道传输数据给子进程时,还需要通过环境变量将数据大小导入给子进程
四:对子进程进行程序替换逻辑
使用exec*
函数对子进程进行程序替换;
不使用线程执行CGI程序的原因:因为linux下的线程是轻量级进程,如果对线程进行程序替换,实际上就是对整个进程(线程组)进行程序替换(因为线程组之间是公用一个页表的),而进程替换就是修改页表的指向,所以使用线程执行cgi程序是不合适的
http处理请求报文的逻辑
项目中将http请求报文解析完毕之后的处理逻辑:
请求方法为GET
请求方法为POST
CGI处理,从正文中获取参数,将参数传递给子进程进行处理,获取执行结果
CGI机制的意义
将上面的各种服务抽象成一个个类即可:
TcpServer
) , Http服务器端(Httpserver
), 业务处理类(EndPoint
), 线程池(ThreadPool
)Log
), 任务类(Task
), 以及与任务类搭配的回调方法(callback
),还有工具方法类(Util
)日志等级
日志信息格式:
#pragma once
#include
#define INFO 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#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(nullptr) << "]" << "[" << message << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}
说明:
__FILE__,__LIEN__
是c语言中的宏定义,作用分别是获取当前文件的文件名,和文件行数;而宏是在文件编译时进行替换的,而我们使用这个俩个宏定义,刚好可以在调用日志方法的文件里编译程序时,获取当前文件的文件名和文件行数套接字服务类
#define BACKLOG 5 // listen 函数第二个参数
class TcpServer
{
private:
int _port;
int listen_sock;
static TcpServer * svr;
private:
TcpServer(int port) : _port(port), listen_sock(-1)
{}
TcpServer(const TcpServer& s) = delete;
TcpServer& operator= (const TcpServer& s) = delete;
public:
static TcpServer * getinstance(int port) // 单例模式 单例对象获取函数
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态锁初始化 不用自己释放
if(nullptr == svr) // 双加锁 防止线程安全问题
{
pthread_mutex_lock(&lock);
if (nullptr == svr){
svr = new TcpServer(port);
svr -> InitServer();
}
pthread_mutex_unlock(&lock);
}
return svr;
}
void InitServer() // 套接字初始化
{
Socket();
Bind();
Listen();
LOG(INFO, "tcp_server init ... success ");
}
int Sock()
{
return listen_sock;
}
// 创建套接字
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)); // socket 端口重用 我们的服务异常终止时 是先进入着time_wait状态的 不能立马重启 就会有很大问题
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; // 云服务器上不能直接绑定公网IP
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 ");
}
~TcpServer() {
if (listen_sock >= 0)
{
close(listen_sock);
}
}
};
TcpServer* TcpServer::svr = nullptr;
说明:
INADDR_ANY
(netinet.h里面定义的宏值),作用是使用所有当前服务器能用的ip接收请求,通常服务端都是服务器,而服务器不止配备了一张网卡,如果只绑定一个ip的话,就只不能将多网卡的优势发挥PTHREAD_MUTEX_INITIALIZER
可以用于初始化全局或静态锁,且该互斥锁不需要我们自己销毁setsockopt
接口解决,服务器服务因为某种原因异常终止了不能立即重启服务的问题; 因为服务异常终止时,主打断开连接的一方最后会处于TIME_WAIT
状态,即服务器服务异常终止后,连接会被保持一段时间,而导致服务器端口被一直占用;使用该接口可以使得对应端口进行复用说明:
key: value
)补充:
我们访问某个资源时,实际上uri部分是划分为俩部分的(path?query_string) query_string 即一些与任务处理相关的参数
class HttpRequest
{
public:
std::string request_line; // 请求行
std::vector<std::string> request_header; // http报头
std::string blank; // 空行
std::string request_body; // http正文字段
// 保存请求行信息
std::string method;
std::string uri; // path?query_string
std::string version;
// uri
std::string path;
std::string query_string;
// 保存报头的key: value
std::unordered_map<std::string, std::string> header_kv;
int content_length;
bool Cgi;
int size; // 请求的资源的大小
std::string suffix; // 所求资源后缀
public:
HttpRequest() :content_length(0), Cgi(false)
{}
~HttpRequest(){}
};
说明:
class HttpResponse
{
public:
std::string status_line;
std::vector<std::string> response_header;
std::string blank;
std::string response_body;
//状态码
int status_code;
int fd;
public:
HttpResponse() :blank(END_LINE), status_code(OK)
{}
~HttpResponse(){}
};
class HttpServer
{
private:
int port; // 服务端口 默认为8081
bool stop; // 判断服务是否停止
public:
HttpServer(int _port = PORT) : port(_port), stop(false)
{}
void InitServer()
{
// 该进程忽略SIGPIPE信号,不然会在线程进行写入时,对端忽然间关闭连接, 会导致server崩溃
signal(SIGPIPE, SIG_IGN);
}
void Loop()
{
LOG(INFO, "Http_server Loop Begin ");
TcpServer* tsvr = TcpServer::getinstance(port);
while(!stop)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len); // 获取连接
if (sock < 0){
continue; // 获取连接失败 继续获取
}
LOG(INFO, "Get a new link ");
Task task(sock);
// 将任务推送到任务队列里
ThreadPool::GetInstance()->PushTask(task);
}
}
~HttpServer()
{}
};
只是简单抽离出来的一个概念,算是线程池和服务端之间的一个联系的桥梁,将任务塞到任务队列里,线程拿到任务,就可以调用该任务的回调函数,拿到任务的线程就可以开始工作了
class Task
{
private:
int sock;
Callback handler;
public:
Task() {}
Task(int _sock) : sock(_sock)
{
}
// 处理任务
void ProcessOn()
{
handler(sock);
}
~Task() {}
};
懒汉模式下实现的线程池
#pragma once
#include
#include
#include
#include "Task.hpp"
#include "Log.hpp"
#define NUM 6
class ThreadPool{
private:
std::queue<Task> task_queue;
bool stop;
int num;
pthread_mutex_t lock;
pthread_cond_t cond;
static ThreadPool* single_instance;
private:
ThreadPool(int _num = NUM): num (_num), stop(false)
{
pthread_mutex_init(&lock, nullptr);
pthread_cond_init(&cond, nullptr);
}
ThreadPool (const ThreadPool& tp) = delete;
ThreadPool& operator=(const ThreadPool & tp) = delete;
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 TaskQueueIsEmpty()
{
return task_queue.empty();
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void ThreadWakeup()
{
pthread_cond_signal(&cond);
}
void ThreadWait()
{
pthread_cond_wait(&cond, &lock);
}
static void * ThreadRoutine(void *args)
{
ThreadPool * tp = (ThreadPool*) args;
while(true){
Task t;
tp->Lock();
while (tp->TaskQueueIsEmpty()){
tp->ThreadWait();
}
tp->PopTask(t);
tp->Unlock();
t.ProcessOn();
}
}
bool InitThreadPool()
{
for (int i = 0; i < num; i++)
{
pthread_t pid;
if ( pthread_create(&pid, nullptr, ThreadRoutine, this) != 0){
LOG (FATAL, "ThreadPool Init error");
return false;
}
LOG(INFO, "ThreadPool Init success");
return true;
}
}
void PushTask(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;
注:
注:本项目只实现了get,post请求方法的相关逻辑
EndPoint即终端,我们将业务类处理为终端类,核心方法:
EndPoint主体逻辑:
//业务端EndPoint
class EndPoint{
private:
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
int _sock; //通信的套接字
bool stop; // 判断业务是是否需要终止
public:
EndPoint(int sock)
:_sock(sock)
{}
//读取请求
void RecvHttpRequest();
//处理请求
void HandlerHttpRequest();
//构建响应
void BuildHttpResponse();
//发送响应
void SendHttpResponse();
~EndPoint()
{}
};
再将其细分
读取请求行 RecvHttpRequestLine
因为HTTP请求报文都是以一行为分割符的,所以我们可以直接按行读,将请求行和请求报文进行读取,但是每行的结束可能会有以下3种情况: \r\n \r \n
我们需要将其都统一处理成同一种格式
我们读取每行采取按字符读取的方式,遇到\n,或是普通字符正常读取即可,\n就是该行结尾了
当读取到\r 时
依照上面代码思路实现的ReadLine方法
static int ReadLine(int sock, std::string& out)
{
char ch = 'X';
while (ch != '\n')
{
ssize_t s = recv(sock, &ch, 1, 0);
if(s > 0)
{
// 处理\r 和 \r\n的情况 将这俩种情况都转化成 \n
if(ch == '\r')
{
// 因为不能判断\r 后面是否具有\n 所以需要先偷窥一下\r 后面是否具有\n
recv(sock, &ch, 1, MSG_PEEK);
if (ch == '\n')
{
// 窥探成功 \r 后面具有 \n
recv(sock, &ch, 1, 0); // 取出\n
}else{
ch = '\n' ; //将\r 处理成 \n
}
}
// \n 或者是正常字符
out.push_back(ch);
}
else if (s == 0)
{
// 客户端断开连接
return 0;
}
else{
return -1; // 出现错误
}
}
}
说明:
RecvHttpRequestLine方法具体实现
\n
在解析字符串并没有什么作用,所以可以在存储时将其去掉,前面不去掉的原因是为了是按行读取的逻辑更清晰bool RecvHttpRequestLine() // 读取请求行
{
auto& line = http_request.request_line;
if( Util::ReadLine(sock, line) <= 0){
stop =true;
}
line.resize(line.size() - 1); // 去掉\n
LOG(INFO, line);
return stop;
}
读请求报头RecvHttpRequestHeader
同上面思路一致,也是按行读取即可,但为了后续分解报头中的属性字段更方便,使用vector将每行数据存储起来;而读取请求报头的停止标志也简单,就是当我们读取到连续的\n时,就说明我们已经读到了空行,就可以停止报头的读取了
bool RecvHttpRequestHeader() // 读取报头
{
std::string line;
while(true){
line.clear();
if (Util::ReadLine(sock,line) <= 0){
stop = true;
break;
}
if (line == "\n"){
http_request.blank = line;
break;
}
line.resize(line.size() - 1);
http_request.request_header.push_back(line);
LOG(INFO,line);
}
return stop;
}
解析请求行ParseHttpRequestLine
请求行大致格式
GET / HTTP/1.1
使用stringstream进行切分,stringstream默认的分割符就是空格
void ParseHttpRequestLine()
{
auto & line = http_request.request_line;
std::stringstream ss(line);
ss >> http_request.method >> http_request.uri >> http_request.version;
//std::cout << "debug: " << http_request.method << std::endl;
auto &method = http_request.method;
std::transform(method.begin(), method.end(), method.begin(), ::toupper);
}
解析请求报头ParseHttpRequestHeader
报头中的属性字段都是以这样形式存在的,所以我们可以直接使用": "作为分割符,将报头中的每行解析出来
key: value
具体实现:
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
for( auto &iter : http_request.request_header)
{
if(Util::CutString(iter, key, value, SEP)){
http_request.header_kv.insert({key,value});
}
}
}
static bool CutString(const std::string &target , std::string &sub1_out, std::string &sub2_out, std::string sep) // 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;
}
说明:
解析请求正文ParseHttpRequestBody
bool IsNeedParseHttpRequestBody() //子函数
{
auto &method = http_request.method;
if(method == "POST"){
auto &header_kv = http_request.header_kv;
auto iter = header_kv.find("Content-Length");
if(iter != header_kv.end()){
LOG(INFO, "POST Method, Content-Length: "+iter->second);
http_request.content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
void ParseHttpRequestBody()
{
if(IsNeedParseHttpRequestBody()){
int content_length = http_request.content_length;
auto &body = http_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{
break;
}
}
LOG(INFO, body);
}
}
构建响应报文其实可以分为俩大部分,一部分是处理上述解析出来的用户请求,另一部分是构建响应报文,所以实际上EndPoint还可以在细分为4部分,这样逻辑可能更清楚一点,但看个人想法,因为觉得是构建响应报文这一类我就将其归进来了
构建响应报文部分组成:
处理用户请求
判断请求方法是否合法,因为本项目只构建了GET和POST方法,所以其他的请求方法都是不合法的
将web根目录转化为本地路径,判断用户请求资源是否存在
2.1 请求资源不存在,直接返回错误响应(404)
请求资源存在情况下,继续判断请求资源的类型
3.1 请求的只是网页内容或是目录文件,直接返回对应的静态网页即可(index.html)即可
3.2 请求的是可执行程序,留给CGI机制处理
CGI机制处理Post请求方法,及其GET请求方法带参情况
void BuildHttpResponse()
{
std::string _path;
struct stat st;
std::size_t found = 0;
auto &code = http_response.status_code;
if(http_request.method != "GET" && http_request.method != "POST" ){
//非法请求
LOG(WARNING, "mothod is not right ");
code = BAD_REQUEST;
goto END;
}
if(http_request.method == "GET"){
ssize_t pos = http_request.uri.find("?");
if (pos != std::string::npos)
{
Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");
http_request.Cgi = true;
}
else
{
http_request.path = http_request.uri;
}
}
else if (http_request.method == "POST"){
// POST
http_request.path = http_request.uri;
http_request.Cgi = true;
}
else{
// do nothing
}
// 转化uri为网络根目录
_path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += _path;
if (http_request.path[http_request.path.size() - 1] == '/' ) {
http_request.path += HOME_PAGE; // path后面没有带指定路径 就只是获取当前网站首页
}
//判断请求资源是否存在
if (stat(http_request.path.c_str(), &st) == 0){
//资源存在
//判断资源是什么 1.目录 2.可执行文件 3.资源
if (S_ISDIR(st.st_mode)){
//1.目录文件 将该目录的默认目录首页html 显示当前目录的首页
http_request.path += '/';
http_request.path += HOME_PAGE;
stat(http_request.path.c_str(), &st) ; // 更新一下st
}
if (st.st_mode & S_IXUSR || st.st_mode & S_IXGRP || st.st_mode & S_IXOTH){
//请求的资源是可执行程序 特殊处理
http_request.Cgi = true;
}
http_request.size = st.st_size; //获取对应文件的大小 即响应报文的正文大小
}
else{
//资源不存在
std::string Info = http_request.path;
Info += "NOT_FOUND";
LOG(WARNING, Info);
code = NOT_FOUND;
goto END;
}
found = http_request.path.rfind("."); // 查询获取资源的后缀
if (found == std::string::npos)
{
http_request.suffix = ".html";
}
else{
http_request.suffix = http_request.path.substr(found);
}
std::cout << "path: " << http_request.path << std::endl;
if (http_request.Cgi){
//ProcessCgi
code = ProcessCgi();
}
else {
//ProcessNonCgi -- 返回静态网页 -- 简单的网页返回
code = ProcessNonCgi();
}
END:
BuildHttpResponseHelper();
}
说明:
stat接口,可以用来判断某个文件是否存在,而后我们可以从中得到一个struct stat结构体,里面保存了许多关于该文件的描述;
其中后面判断一个文件是否为目录文件就是用S_ISDIR()接口和st_mode判断以及st_size获取该资源长度
构建响应状态行BuildHttpResponseHelper
状态行包括如下格式:
HTTP/1.1 200 OK
所以我们只需将这3个字段拼接起来即可
注:
void BuildHttpResponseHelper()
{
//构建状态行
auto &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 += Code2Desc(code);
status_line += END_LINE;
//出现差错时 path并未被构建 或是有错误
std::string path = WEB_ROOT;
path += "/";
switch(code)
{
case OK:
BuildOkResponse();
break;
case BAD_REQUEST:
path += PAGE_400;
HandlerError(path);
break;
case NOT_FOUND:
path += PAGE_404;
HandlerError(path);
break;
case SERVER_ERROR:
path += PAGE_500;
HandlerError(path);
break;
default:
break;
}
}
构建响应报文HandlerError , BuildOKResponse
HandlerError
主要用于构建错误情况下的报文处理;
这里只提及几点处理细节:
BuildResponse
void HandlerError(std::string page)
{
http_request.Cgi = false;
http_response.fd = open(page.c_str(), O_RDONLY);
if (http_response.fd > 0)
{
struct stat st;
stat(page.c_str(), &st);
std::string line = "Content-Type: text/html";
line += END_LINE;
http_response.response_header.push_back(line);
http_request.size = st.st_size; // 如果这里不更新 size就会为0 因为前面并不会更新 因为请求的资源不存在
line = "Content-Length: ";
line += std::to_string(http_request.size);
line += END_LINE;
http_response.response_header.push_back(line);
}
}
void BuildOkResponse()
{
std::string line = "Content-Type: ";
line += Suffix2Desc(http_request.suffix);
line += END_LINE;
http_response.response_header.push_back(line);
line = "Content-Length: ";
if (http_request.Cgi){
line += std::to_string (http_response.response_body.size());
}
else {
line += std::to_string(http_request.size); //Get 不带uri -- 返回静态网页部分
}
line += END_LINE;
http_response.response_header.push_back(line);
}
组装和发送响应报文SendHttpResponse
说明:
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);
//std::cout << "debug: " << http_response.response_body.c_str() << std::endl;
if (http_request.Cgi){
auto &response_body = http_response.response_body;
int total = 0;
int size = 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, http_response.fd, nullptr, http_request.size);
close(http_response.fd);
}
}
错误分类
逻辑错误 – 该项在上面的运行逻辑已经处理了,例如: 404 400 等状态码
读取错误
写入错误
下面只讨论读读取和写入错误是什么且如何解决
读取错误
读取错误指的就是我们在从套接字中读取对端请求报文时,对端将套接字服务关闭,导致我们发生读取中段情况;
因为我们实际读取请求报文时分为以下5步,读取请求行,读取报头,解析状态行,解析报头,读取正文;如果读取请求行出现错误,后面的步骤就不需要进行了,读取报头出错,后面也不需要进行了,所以我们可以将读取和解析报文方法进行大概调整
处理: 及时止损,将连接关闭即可
void RecvHttpRequest()
{
if ( (!RecvHttpRequestLine()) && (!RecvHttpRequestHeader()) )
{
ParseHttpRequestLine();
ParseHttpRequestHeader();
ParseHttpRequestBody();
}
}
写入错误
写入错误就是指当我们将响应报文写入到套接字接口时,对端直接将连接关闭的时,我们会收到SIGPIPE信号将我们进程终止掉(写端向一个已经关闭的连接进行写入,os会认为这是一种极其浪费资源的行为,直接发送信号将我们终止),而我们执行业务的是我们的线程,如果我们进程被终止掉了,就意味着我们服务直接挂了;
处理方式: 在服务器类中屏蔽掉SIGPIPE信号
// 该进程忽略SIGPIPE信号,不然会在线程进行写入时,对端忽然间关闭连接, 会导致server崩溃
signal(SIGPIPE, SIG_IGN);
项目测试使用的软件:[Postman](Postman API Platform | Sign Up for Free)
直接傻瓜式安装即可
使用Postman 发送如下http报文
Get / HTTP/1.0
也可以使用telnet 直接发送上述内容,查看响应报文
服务器起来日志信息正常
业务正常,日志信息显示正确
网站首页我设置的是一个注册表单,响应正常
测试方法:请求一不存在的资源;
例如发送如下http报文
Get /a/b/c HTTP/1.0
运行结果:
日志信息打印:
测试方法:使用不支持的请求方法进行资源请求
delete /a/b HTTP/1.0
日志信息打印正确:
响应报文无误:
500报错因为是服务器内部报错,需要我们自己构造出来
测试方法:
请求首页报文,人为错误,在构建响应阶段直接返回报错错误码
将该条信息放置于ProcessNonCgi()中
LOG(ERROR, "make SERVER_ERROR For Test");
return SERVER_ERROR;
日志信息打印:
响应无误:
为了测试方便,将首页更换成计算器表
DOCTYPE html>
<html>
<head>
<meta charset = "UTF-8">
head>
<body>
<form action="/test_cgi" method="get">
x:<input type="text" name="data_x" value="0">
<br>
y:<input type="text" name="data_y" value="1">
<input type="submit" value="提交">
form>
<p>如果您点击提交,表单数据会被发送到cgi,进行处理p>
body>
html>
测试方法:使用get方法 在表单位置传输账号密码
响应结果:
通过正文部分传递参数
请求报文大致如下
Post /test.cgi HTTP/1.0
Content-Type: text/html
Content-Length: 11
a=100&b=200
日志信息打印:
响应构建无误
项目源码
项目扩展: