项目名称: 自主HTTP服务器
功能描述: 采用B/S模型,基于短链接方式的HTTP服务器,目前支持GET,POST方法。
引入CGI模式,可以通过表单上传数据经由服务器创建子进程处理发送回父进程,由父进程构建响应发送给客户端。
引入了日志,将服务器启动的信息按照固定格式输出在标准输出当中。
涉及知识: 网络编程(套接字编写),多线程技术,CGI技术,shell脚本,线程池,进程间通信(管道),哈希表,向量等STL容器。
实现环境: centos 7.6 + vim/gcc/gdb + C/C++
第一步来咯~
编写套接字的工作比较简单,在这里值得一提的是用了setsockopt当中的SO_REUSEADDR,让服务器即使宕机了也能立马绑定端口号。
setsockopt:
第一步骤:创建一个TcpSever的单例对象。
先前博客有介绍,这里不细说,点击链接跳转码云~
TcpServer.hpp
HttpServer只调用先前单例对象TcpServer,让TcpServer将服务器起来,然后HttpServer实际上只负责将链接获取上来,然后制造一个任务,将任务放入线程池,让线程池在合适的时候进行处理。
先前博客有介绍,这里不细说,点击链接跳转码云~
HttpSever.hpp
ThreadPool.hpp
线程池实际上在之前的博客当中有详细讲解,这里唯一值得一题的就是巧妙地编写了Task的回调,这让线程池拿到任务并且处理的逻辑就不用挤在一起了。
每一个任务封装了一个CallBack 对象,调用的时候只需要传入套接字给对象,就可以实现功能。
#pragma once
#include"Protocol.hpp"
#define INFO 1
#define WARMING 2
#define ERROR 3
#define FATAL 4
class Task{
private:
CallBack callback;
int* sock;
public:
Task(int* _s):sock(_s)
{}
void operator()()
{
callback(sock);
}
};
ReadLine的具体功能是用来读取一行数据,并将末尾置成\n结尾。
static int ReadLine(int sock,std::string& str)
{
//从sock当中读取若干信息,并对换行分隔符做处理
char ch = 'X';
while(ch != '\n')
{
//往ch放入1个字节数据
ssize_t s = recv(sock,&ch,1,0);
if(s > 0)
{
if(ch == '\r'){
//此时需要窥探下一个字符是否为'\n'
recv(sock,&ch,1,MSG_PEEK);
if(ch == '\n')
{
//若时\n,则覆盖式将ch填入\n
recv(sock,&ch,1,0);
}
else
{
//说明是下一行信息,则将ch变为\n即可
ch = '\n';
}
}
// 普通字符
str += ch;
}
else if(s == 0){
//链接关闭了
return 0;
}
else{
//读取出错
perror("recv");
exit(5);
return -1;
}
}
return str.size();
}
CutString是将target字符串按照sep分成key_out和value_out两部分。
static bool CutString(const std::string &target, std::string &key_out, std::string &value_out, std::string sep)
{
size_t pos = target.find(sep);
if (pos != std::string::npos)
{
key_out = target.substr(0, pos);
value_out = target.substr(pos + sep.size(), std::string::npos);
return true;
}
return false;
}
试问浏览器发送报文到我们的服务器,我们的服务器处理任务分成几步骤
CallBack模块运用了EndPoint当中的HttpRequest,封装了以下字段。
读取和解析部分实际上就是填写EndPoint类对象的具体字段。
封装回应报文,发送数据实际上就是填写HttpResponse当中字段,然后区分是CGI模式,若不是,发送对应请求路径的页面即可,若是CGI,则发送response_body回去。
class HttpRequest
{
public:
std::string request_line; //请求行
std::vector<string> request_header; //请求报头,kv模式
std::string blank; //空行
std::string request_body; //请求正文
std::string method; // 请求方法
std::string uri; //请求uri
std::string version; //请求的版本,长短链接
std::unordered_map<std::string, std::string> header_kv; //请求头部的key映射value,方便类似Content-Length的查找
int content_length;
std::string path; //访问的路径
std::string query_string; //请求的参数
bool cgi;
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 code; //状态码
int fd; //发送数据的文件描述符的地点
int size; //浏览器定位的路径文件的大小
int content_length;//CGI过后真正要发送的内容
std::string suffix;//后缀
HttpResponse() : code(404), fd(-1), size(0), content_length(0), blank(LINE_END)
{}
~HttpResponse()
{}
};
http请求分为请求行,请求报头,空行和请求正文的,要保证读上来的是一个完整的http请求,读到空行就是读完了请求行和请求报头,请求报头的Content-Legth标识了请求正文的长度,而请求正文的长度不一定是按照行来成列的,通常大部分公司会把这部分的空行取消,让数据包比较小。而空行之前都是按分割符成列的。
每个步骤剖析
以下实验是谷歌浏览器
该函数只读取第一行,也就是请求行,我们注意不同浏览器发送的请求可能换行不同,如\r\n
,\n
,我们这里要进行处理,方便适配更多浏览器。
一开始只读取请求行,可以看到请求行的method是大写的,访问的路径是/
,即根目录,版本是HTTP/1.1
特殊情况:当读到\r的时候,需要读一个字符判断是否是\n,若不是,则不能将它从缓冲区拿走,若贸然拿走,则下图就会读到请求头部的数据,导致读取报头出错!!!
解决方案:
此时需要用到recv的一个flags字段,它能从底层的缓冲区获取并且不移除该数据。MSG_PEEK
MSG_PEEK(man手册)
This flag causes the receive operation to return data from the beginning of the receive queue without removing
that data from the queue. Thus, a subsequent receive call will return the same data.
// 1.接受请求行,只读取一行
bool RecvHttpRequestLine()
{
std::string &line = http_request.request_line;
//读取浏览器发送的请求行数据,填入结构体字段
if (Util::ReadLine(sock, line) > 0)
{
//由于Util::ReadLine方法会将\n也一同放入line,所以这里我们需要手动删除
line.resize(line.size() - 1);
LOG(INFO, line);
}
else
{
stop = true;
}
return stop;
}
读完了请求行,接下来读请求头部,请求头部的格式Content-Length: 126
,注意其中的分隔符是冒号+空格,这个很重要。我们读取的时候先使用vector
这个容器来接受,方便接受。
该函数可以读完请求头部和空行。
// 2.读取请求头部,也会同时填充空行字段,有若干行,以空行作为分隔符,读取到的临时存放到request_header,5步骤转化为哈希表存储
bool RecvHttpRequestHeader()
{
//若干行,所以这里需要循环读取
while (true)
{
std::string line;
// 1.读取失败,读取上来返回值小于等于0
if (Util::ReadLine(sock, line) <= 0)
{
stop = true;
break;
}
// 2.读取到空行,此时设置空行字段
if (line == "\n")
{
//若没有读取到空行,则blank为""
http_request.blank = line;
break;
}
// 3.读取到了例如Content-Length: 36的字段
line.resize(line.size() - 1);
//由vector 统一保存
http_request.request_header.push_back(line);
LOG(INFO, line);
}
return stop;
}
解析的原因如下
解析的原因:
vector
,但是如果我们后续需要更多的字段都遍历那么效率就不高,所以我们添加unordered_map
,这样就可以通过搜索Content-Length
字段得知我们是否需要读取以及读取多少内容当作正文。解析请求行:
注意:
// 3.解析请求行,填充method,url,version字段
void ParseHttpRequestLine()
{
//采用stringstream,默认读到string当中以空格为分隔
std::stringstream ss(http_request.request_line);
ss >> http_request.method >> http_request.uri >> http_request.version;
std::string &method = http_request.method;
// get/Get/GET 统一转化为大写GET
transform(method.begin(), method.end(), method.begin(), ::toupper);
LOG(INFO, http_request.method);
LOG(INFO, http_request.uri);
LOG(INFO, http_request.version);
}
解析请求头部。填充入哈希表,方便后续使用。
// 4.解析请求头部,同时告知是否有正文(content_length)
void ParseHttpRequestHeader()
{
std::string key, value;
//初始化哈希表header_kv
for (auto &str : http_request.request_header)
{
// SEP是:+空格,CutString这里将一个个string重新切分到哈希表当中
//方便后续直接通过哈希表O(1)查找类似Content-Length,不需要到request_header当中遍历寻找
Util::CutString(str, key, value, SEP);
std::cout << "debug " << key << ":" << value << std::endl;
http_request.header_kv[key] = value;
}
读取正文需要判断是否有,以及若要读取多少的问题,所以我们采用一次性读一个字符,这样子当读不到就说明报文有问题,此时可以销毁这个套接字。stop字段的使用如果在读取阶段错误我们删除套接字,后续过程出错我们返回错误页面。
// 5.1 是否需要读取正文,若是POST并且存在Content-Length返回true,其他返回false
bool IsNeedRecvHttpRequestBody()
{
if (http_request.method == "POST")
{
auto iter = http_request.header_kv.find("Content-Length");
if (iter != http_request.header_kv.end())
{
// 1.找到了Content-Length
//设置请求结构的content_length正文长度
http_request.content_length = stoi(iter->second);
return true;
}
else
{
// 2.找不到Content-Length
return false;
}
}
return false;
}
// 5. 读取正文,返回值为stop表示是否需要暂停
bool RecvHttpRequestBody()
{
//判断是否需要读取正文
if (IsNeedRecvHttpRequestBody())
{
std::string &body = http_request.request_body;
int content_length = http_request.content_length;
char ch = 0;
//一个字符的读取,防止粘包
while (content_length)
{
if (recv(sock, &ch, 1, 0) > 0)
{
content_length--;
body += ch;
}
else
{
//读取失败就不用继续了
stop = true;
break;
}
}
}
return stop;
}
制造回应的过程比较复杂。
分为以下步骤:
判断路径是否存在:
SYNOPSIS
#include
#include
#include
int stat(const char *path, struct stat *buf);
struct stat {
dev_t st_dev; /* ID of device containing file */
ino_t st_ino; /* inode number */
mode_t st_mode; /* protection */
nlink_t st_nlink; /* number of hard links */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
dev_t st_rdev; /* device ID (if special file) */
off_t st_size; /* total size, in bytes */
blksize_t st_blksize; /* blocksize for file system I/O */
blkcnt_t st_blocks; /* number of 512B blocks allocated */
time_t st_atime; /* time of last access */
time_t st_mtime; /* time of last modification */
time_t st_ctime; /* time of last status change */
};
mode_t st_mode; /* protection */使用的较多
存在需要考虑的情况:
Linux的目录结构是树状从跟开始,但是这里的请求报头的/
并不是Linux的根目录开始,通常我们会有一个web目录,我们所提供的jsp页面,等等图片,视频资源通常放在web目录下。
我们通常由http服务器设置为自己的WEB根目录,就是一个Linux下一个特定的路径。我创建的wwwroot就是网页的目录。
// 6.BulidHttpResponse负责将内部负责报文的完成,并且处理两种模式
// 需要判断是否为CGI程序,以及需要处理访问路径
void BulidHttpResponse()
{
std::string path;
struct stat st;
size_t suffix_index;
int size = 0; //文件的大小
//
///判断是否为CGI程序
//判断请求方法是否正确
if (http_request.method != "GET" && http_request.method != "POST")
{
LOG(WARNING, "method wrong");
http_response.code = BAD_REQUEST;
goto END;
}
//判断是否是CGI模式
if (http_request.method == "GET")
{
if (http_request.uri.find("?") != std::string::npos)
{
Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");
// http_response.content_length = http_request.query_string.size();
http_request.cgi = true;
}
else
{
http_request.path = http_request.uri;
}
}
else if (http_request.method == "POST")
{
http_request.path = http_request.uri;
http_request.cgi = true;
}
else
{
//若有其他GET/POST其它的类型,可以在这里添加....TODO
}
//
///调整访问路径
///添加wwwroot/ ,防止用户从根目录开始访问
//
path = http_request.path;
http_request.path = WEBROOT;
http_request.path += path;
//若客户访问为111.111.111.111:8080/,浏览器会自动帮你在末尾添加/,此时我们只需要指定访问index页面
if (http_request.path[http_request.path.size() - 1] == '/')
{
http_request.path += HOME_PAGE;
std::cout << "DEBUG path:" << http_request.path << std::endl;
}
else
{
std::cout << "DEBUG path:" << http_request.path << std::endl;
}
//
///判断路径是否合法
/
if (stat(http_request.path.c_str(), &st) == 0)//路径合法
{
//1.该文件是目录,需要添加index.html
if (S_ISDIR(st.st_mode))
{
http_request.path += HOME_PAGE;
//添加HOME_PAGE过后需要更新路径
stat(http_request.path.c_str(), &st);
}
//2.文件若是拥有者执行执行,组内执行,其他人执行,即该文件只要有执行权限,也需要设置CGI模式
if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH))
{
//设置CGI模式
http_request.cgi = true;
}
//3.请求文件的大小设置
size = st.st_size;
http_response.size = size;
}
else//路径不合法,跳转到响应404页面
{
//资源不存在
std::string path = http_request.path;
path += " NUTFOUND";
LOG(WARNING, path + " Not Found");
http_response.code = NOT_FOUND;
goto END;
}
//处理后缀
suffix_index = http_request.path.rfind(".");
if (suffix_index == std::string::npos)
{//请求的路径没有后缀,默认设置.html
http_response.suffix = ".html";
}
else
{//设置后缀字段
http_response.suffix = http_request.path.substr(suffix_index);
}
//走到这里默认设置code为正常200
http_response.code = 200;
//
///处理请求的需求
//
if (http_request.cgi == false)
{
//只负责打开文件描述符,END统一建立回应
http_response.code = ProcessNonCgi();
}
else
{
http_response.code = ProcessCgi();
}
END:
BulidHttpResponseHelper();
return;
}//end of BulidHttpResponse
CGI程序需要根据GET,POST方法读取不同的字段(环境变量)或者管道里的数据,已经重定向,所以可以通过标准输出获取进行处理,CGI程序可以用任何语言实现。
CGI(Common Gateway Interface)是WWW技术中最重要的技术之一,有着不可替代的重要地位(网络服务器和后端业务程序耦合重要机制,理解PhP/Python/Java如何把网络里的数据拿到自身的程序中)。CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的过程。
HTTP并不负责处理数据,HTTP负责数据的传递,但是HTTP会提供某种方式对数据进行处理,即CGI模式。
HTTP 调用目标程序,传递目标数据,获取目标结果 --CGI
大部分语言底层都会封装CGI,我们这里旨在学习。通过这个可以理解http如何给我们的java,py拿到数据并响应。
我们这里将整个文件传输过去即可。
SYNOPSIS
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
从in_fd读,写到out_fd,offset为nullptr,count表示你想拷贝的字节数。
CGI程序可以通过输出将结果带回给父进程,并且由于子进程关闭,父进程读端会读到0,即结尾。
#include
using namespace std;
#include
int main()
{
cerr << getenv("METHOD") << endl;
return 0;
}
CGI程序和非CGI程序处理函数详解~
ProcessNonCgi只需要返回打开的文件,比较简单,返回文件描述符,在BuildOk当中就会将该文件用sendfile这种0拷贝技术进行拷贝回去。
//6.2处理非CGI程序
int ProcessNonCgi()
{
http_response.fd = open(http_request.path.c_str(), O_RDONLY);
if (http_response.fd >= 0)
{
return OK;
}
return NOT_FOUND;
}
CGI模式的处理稍微复杂,分为以下几步骤
BulidHttpResponse函数内部这里的切分了query_string是不包含?
的。
如https://blog.csdn.net/weixin_52344401/article/details/125898517?a=10
,则path是https://blog.csdn.net/weixin_52344401/article/details/125898517
,query是a=10
。
重定向的目的:子进程程序替换过后,虽然文件描述符表还存在,但是在上层,进程的代码和数据都被替换了,所以需要通过重定向,往自定义协议0为读,1为写。
SYNOPSIS
#include
int putenv(char *string);
SYNOPSIS
#include
char *getenv(const char *name);
///
处理CGI程序,需要创建子进程处理,response_body此时才会被设置
//
int ProcessCgi()
{
std::string &bin = http_request.path;
auto &code = http_response.code;
//以父进程的角度创建两个管道进行父子之间通信
int outfd[2];
int infd[2];
if (pipe(outfd) < 0)
{
LOG(ERROR, "create pipe error!");
code = SERVER_ERROR;
return code;
}
if (pipe(infd) < 0)
{
LOG(ERROR, "create pipe error!");
code = SERVER_ERROR;
return code;
}
pid_t pid = fork();
if (pid < 0)
{
LOG(ERROR, "fork fail!");
code = SERVER_ERROR;
return code;
}
else if (pid == 0)
{
// child
///
///子进程负责将环境变量设置好,进行进程替换给对应的进程处理数据
///
close(outfd[1]);
close(infd[0]);
//提前把方法传递给子进程
std::string method = "METHOD=";
method += http_request.method;
putenv((char *)method.c_str());
//若是GET方法,子进程将QUERY_STRING的内容设置到环境变量即可
if (http_request.method == "GET")
{
std::string query_string_env = "QUERY_STRING=";
query_string_env += http_request.query_string;
putenv((char *)query_string_env.c_str());
LOG(INFO, "GET putenv: " + query_string_env);
}
//若为POST方法,子进程将要接受LENGTH长度以环境变量发送
else if (http_request.method == "POST")
{
std::string content_length_env = "LENGTH=";
content_length_env += std::to_string(http_response.content_length);
putenv((char *)content_length_env.c_str());
LOG(INFO, "POST Putenv :" + content_length_env);
}
//进行重定向
//子进程outfd[0]里读,infd[1]里写
dup2(outfd[0], 0);
dup2(infd[1], 1);
//子进程进程替换过后环境变量不会改变,此时协议规定往0文件描述符是写,往1是读。
execl(bin.c_str(), bin.c_str(), nullptr);
//进程替换后这里后面若执行就是出错了
LOG(WARNING, "execl fail!");
exit(1);
}
else
{
///
父进程负责发送请求正文给子进程,对子进程进行等待
///
// father
close(outfd[0]);
close(infd[1]);
//子进程已经获取了LENGTH字段,父进程发送请求正文给子进程处理
if (http_request.method == "POST")
{
size_t total = 0; //以发送多少
const char *start = http_request.request_body.c_str(); // start指向数据起始
ssize_t size = 0; //一次发送的数据
while (total < http_request.request_body.size() && (size = write(outfd[1], start + total, http_request.request_body.size() - total)) > 0)
{
total += size;
}
}
int status = 0;
int ret = waitpid(pid, &status, 0);
if (ret == pid)
{
// std::cerr << "debug father read success: 1"<< std::endl;
char ch = 0;
if (WIFEXITED(status))
{
// std::cerr << "debug father read success: 2"<< std::endl;
if (WEXITSTATUS(status) == 0)
{
// std::cerr << "debug father read success: 3"<< std::endl;
//正常退出,写端关闭,读端会读到0
while (read(infd[0], &ch, 1))
{
//子进程会自动退出,读上来的数据放进response_body当中
http_response.response_body.push_back(ch);
std::cerr << "debug father read success: " << ch << std::endl;
}
//response_body此时才会被设置
std::cerr << "debug :" << http_response.response_body << std::endl;
code = OK;
}
else
{
code = BAD_REQUEST;
}
}
else
{
code = SERVER_ERROR;
}
}
close(outfd[1]);
close(infd[0]);
}
return code;
}
HandlerRequest是CallBack类当中最重要的模块,最终就是调用这个函数,进行一系列操作,结束过后销毁ep对象析构函数会把文件描述符关闭,断开连接。至此,基于短链接多线程池的HTTP服务器就完成了。
void HandlerRequest(int *_sock)
{
int sock = *(int *)_sock;
std::cout << "get a new link ...: " << sock << std::endl;
// EndPoint负责关闭文件描述符
EndPoint *ep = new EndPoint(sock);
ep->RecvHttpRequest();
if (!ep->Stop())
{
LOG(INFO, "Recv no error,begin Build and Send");
ep->BulidHttpResponse();
ep->SendHttpResponse();
}
else
{
LOG(WARNING, "Recv Error ,Stop Bulid And Send");
}
delete ep;
LOG(INFO, "handler event sucess");
#endif
return;
}
最后需要通过退出码得到描述状态信息,使用Code2Desc即可。
std::string Code2Desc(int code)
{
std::string res;
switch (code)
{
case 404:
res = "Not Found";
break;
case 200:
res = "OK";
break;
}
return res;
}
这个工具类读取流的时候是否要加锁??
工具类的使用都在Task的栈上的对象当中,所以不用。
构建日志能够有利于帮助我们快速定位问题,解决问题!
日志是我们定位问题的一个比较重要的依据,我们只是需要输出日志格式的函数即可。
[日志级别][时间戳][日志信息][错误文件名称][行数]
日志分级别:
INFO:正常输出
WARNING:表示一些告警。
ERROR:有一些错误。
FATAL:致命信息,程序不能往后面走了。
时间戳可以通过一些函数直接获取时间戳,时间戳获取时间time函数。
time_t是一个无符号整数。
文件名称和行数可以通过C提供的一些。
__FILE__
会显示所在文件
__LINE__
会显示所在行数
我们这里致命的信息会打印感叹号。
Log.hpp实现,通过提供LOG接口,让Log传参的时候不需要频繁传__LINE__
和__FILE__
。
#pragma once
#include
#include
#include
#define LOG(level,message) Log(#level,message,__FILE__,__LINE__)
void Log(std::string level, std::string message, std::string filename, size_t line)
{
std::cout << "[ " << level << " ]" <<"[ "<<time(nullptr) <<" ]"<< "[ " << message << " ]" << "[ " << filename <<" ]" << "[ " << line << " ]" << std::endl;
}
cgi程序几乎可以用任何的后端语言来实现。
注意:
cgi程序要输入,不能使用cout了,cout就是往标准输出打印了,已经被重定向到管道了,所以我们要打印内容到终端,可以使用cerr
代码的生成
bin=httpserver
cgi=test_cgi
CC=g++
LD_FLAGS=-std=c++11 -lpthread
src=main.cc
curr=$(shell pwd)
ALL:$(bin) $(cgi)
.PHONY:ALL
$(bin):$(src)
$(CC) -o $@ $^ $(LD_FLAGS)
$(cgi):cgi/test_cgi.cc
$(CC) -o $@ $^
.PHONY:clean
clean:
rm -f $(bin) $(cgi)
生成output文件
.PHONY:clean
clean:
rm -f $(bin) $(cgi)
rm -rf output
.PHONY:output
output:
mkdir -p output
cp $(bin) output
cp -rf wwwroot output
cp $(cgi) output/wwwroot
处理HTTP的两种错误:
所以Endpoint需要添加bool stop变量,默认stop为false。
上述第二种错误:
父进程写入的时候出错,或者读端因为某些原因而关闭了,或者由于网络因素关闭了,此时写端会收到信号SIG_PIPE,此时我们的进程会被退出!
Debug选项打开:
至此,HttpServer就完成了,后续还会更新相关数据库连接,以及CGI程序的一个优化,弄出一个更有意义的页面。
码云直达