文章目录
项目简介
什么是web服务器: 通过HTTP协议构建一个web服务器,该服务器能够处理浏览器发过来的http请求,并根据http请求返回相应的http响应给浏览器。相当于搭建个人网站,你可以在个人网站上存放各种资源,别人可以通过浏览器访问你的网站,并获取资源。
背景: 该项目主要用http协议,在网络中,http协议被广泛使用如移动端,pc端浏览器。http协议是打开互联网应用窗口的重要协议,它在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。
目标: 该项目的目标是,对http协议的理论有更深刻的理解,从零开始完成web服务器开发,连接下三层协议,从技术到应用,让网络难点无处遁形。
简述: 采用C/S模型,编写支持中小型应用的http,理解常见互联网应用行为,做完该项目,你可以从技术上完全理解从你上网开始,到关闭浏览器的所有操作中的技术细节。
技术特点: 该项目是一个后端开发项目,开发环境为centos 7 + vim/gcc/gdb/VS Code,开发语言为C/C++,应用网络编程(TCP/IP协议, socket流式套接字,http协议),多线程,cgi,线程池等技术。
认识http协议
http分层概览
与http相关的重要协议有TCP,IP,DNS
这里介绍一下DNS
DNS(域名系统)是将域名和IP地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。
协议之间是如何协同运作的呢?
http背景知识补充
目前主流服务器使用的是http/1.1版本,该项目是按照http/1.0版本来完成讲解,同时,我们还会对比1.1和1.0的区别。
http协议的特点如下
- 简单快速,HTTP服务器的程序规模小,因而通信速度很快。
- 灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
- 无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)。
- 无状态(HTTP协议自身不具有保存之前发送过的请求或响应的功能)
注意: http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。
URI & URL & URN
我们想访问web上资源需要用资源标志符(URI)进行定位,和URI相关的还有URL,URN。
- URI,是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源。
- URL,是uniform resource locator,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
- URN,uniform resource name,统一资源命名,是通过名字来标识资源。
URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。URL和URN都是一种URI,URL是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI 。如果这个标识是一个可获取到上述对象的路径,那么同时它也可以是一个 URL ;但如果这个标识不提供获取到对象的路径,那么它就必然不是URL 。
HTTP URL (URL是一种特殊类型的URI,包含了如何获取指定资源)的格式如下:
http://host[":"port][abs_path]
- http表示要通过HTTP协议来定位网络资源
- host表示合法的Internet主机域名或者IP地址,本主机IP:127.0.0.1
- port指定一个端口号,为空则使用缺省端口80。(通常会根据协议采用默认端口号)
- abs_path指定请求资源的URI
- 如果URL中没有给出abs_path,那么当它作为请求URI时,必须以“/”的形式给出,通常这个工作浏览器自动帮我们完成。(如果没有abs_path,会默认访问该服务端首页)。
例:
一个较为完整的http请求:
http://www.aspxfans.com:8080/news/index.asp?boardID=5&ID=24618&page=1
构建tcp服务器
我们这里的tcp服务器是对网络套接字创建,绑定,监听等进行封装,方便之后的使用。
这里我们把 tcp server 类设计为单例模式,让程序访问到的 tcp server 是同一个。代码如下
#pragma once
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<pthread.h>
#define PORT 8081
#define BACKLOG 5
class TcpServer{
private:
int port;// 端口号
int listen_sock;// 套接字
static TcpServer *svr;
private:
TcpServer(int _port = PORT):port(_port),listen_sock(-1)
{}
TcpServer(const TcpServer &s){}
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);// 创建tcpserver
svr -> InitServer();// 初始化
}
pthread_mutex_unlock(&lock);
}
return svr;
}
void InitServer()// 初始化
{
Socket();
Bind();
Listen();
}
void Socket()
{
listen_sock = socket(AF_INET,SOCK_STREAM,0);// 创建套接字
if(listen_sock < 0){
exit(1);
}
int opt = 1;
setsockopt(listen_sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));// 设置套接字选项,允许地址复用
}
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){// 绑定失败
exit(2);
}
}
void Listen()
{
if(listen(listen_sock,BACKLOG)<0){
exit(3);
}
}
~TcpServer()
{}
};
TcpServer* TcpServer::svr = nullptr;
关于setsockopt函数可以参考这篇博客setsockopt()函数功能介绍
HTTP请求和响应
请求和响应过程
浏览器请求服务器资源需要给服务器发送http请求,服务器收到请求需要返回http响应给浏览器,我们需要详细了解请求和响应的格式,为之后编码做准备。
HTTP请求报文
HTTP请求报文分为 请求行,请求报头,空行,请求正文
- 请求行: 请求方法,请求URL,HTTP协议和版本
- 请求报头: 请求属性,每一行表示一个属性,遇到空行表示请求报头结束。
- 空行:用于分割请求报头和请求正文
- 请求正文:如果存在正文,请求报头中就需要Content-Length属性来标识正文的大小
HTTP响应报文
HTTP响应报文分为 响应行,响应报头,空行,响应正文
- 响应行:http协议和版本,状态码,状态描述符
- 响应报头:响应属性,每一行表示一个属性,遇到空行表示响应报头结束。
- 空行:分割响应报头和响应正文
- 响应正文:服务器返回的HTML页面或者json数据
工具类
我们在编写协议时难免会有一些程序经常用到,如:读取报头中的一行。我们可以设计一个工具类,将这些程序存放其中。
ReadLine()函数
ReadLine()函数的作用是读取sock套接字中的一行数据。无论是请求报头还是响应报头,它们的属性都是按行进行划分的。但是不同的浏览器发送过来的http请求,它们中的行分割符是不同的,大致分为如下三类
- xxxxx \r\n
- xxxxx \n
- xxxxx \r
我们要设计一个算法,兼容各种行分割符。
读取数据,如果读取到 \n 表示该行已结束,如果读取到 \r ,需要判断下一个字符是否为 \n 。不是,那么该行已结束,否则需要将 \r\n 转变为 \n。
这里我们需要用到 recv 函数
recv函数
功能: 接收已连接的数据报或流式套接口的数据。
函数原型:
int recv( int sockfd, void *buf, size_t len,int flags);
参数说明:
- sockfd :发送数据的套接字
- buf :存放recv函数接收到的数据
- len :buf的长度
- flags :recv发送数据的选项,一般设置为0
返回值: 成功返回0,错误返回-1
编码
#pragma once
#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>
//工具类
class Util{
public:
static int ReadLine(int sock,std::string &out)
{
char ch = 'x';
while(ch != '\n'){
ssize_t s = recv(sock,&ch,1,0);// 从sock中读取一个字符到ch中
if(s > 0){
if(ch == '\r'){
recv(sock,&ch,1,MSG_PEEK);// MSG_PEEK 窥探下一个字符(不从sock中取走)
if(ch == '\n'){
// 把\r\n -> \n
// 窥探成功读取该字符
recv(sock,&ch,1,0);
}
else{
ch = '\n';// \r -> \n
}
}
// 到这有两种情况 1.普通字符 2.\n
out.push_back(ch);
}
else if(s == 0){
return 0;
}
else{
return -1;
}
}
return out.size();
}
};
CutString()函数
CutString()函数的功能是将字符串按特定字符切分成左右两部分,代码如下
#pragma once
#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>
//工具类
class Util{
public:
static int ReadLine(int sock,std::string &out)
{
...
}
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;
}
// 传入
// target = Content-Length : 18
// sep = " : "
// 传出
// sub1_out = Content-Length
// sub2_out = sep
};
日志信息
为了方便代码解析和调试,我们需要设计函数来记录日志信息,日志信息的内容如下
乍一看传入的参数是不是有点多,实际上我们只需传入 日志级别 和 日志信息 这两个参数,剩下的参数我们可以让操作系统去查找,为此可以设置宏,通过宏替换来获取 日志文件 和 代码行数 。
#pragma once
#include<iostream>
#include<string>
#include<ctime>
#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;
}
请求和响应类的设计
http服务器收到一个请求时,服务器需要做如下工作:读取请求,分析请求,构建响应,发送响应。为了更好的管理请求报头和响应报头,我们需要设计一个类来存放请求报头和响应报头。
http请求类
class HttpRequest{
public:
std::string request_line;// 请求行
std::vector<std::string> request_header;// 请求报头
std::string blank;// 请求空行
std::string request_body;// 请求正文
// 请求行的解析
std::string method;
std::string uri;
std::string version;
// 请求报头解析
std::unordered_map<std::string, std::string> header_kv;// 属性名:属性信息
int content_length;// 请求正文长度
// URI解析
std::string suffix;// 请求文件名的后缀
std::string path;// URI路径
std::string query_string;// URI参数
bool cgi;// cgi处理标志符
int size;// 打开的文件大小
public:
HttpRequest():content_length(0),cgi(false){}
~HttpRequest(){}
};
http响应类
#define LINE_END "\r\n"
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(LINE_END),status_code(OK),fd(-1){}
~HttpResponse(){}
};
请求处理
请求报文
处理请求报头时,先将报头的数据读取出来,再对其进行处理
void RecvHttpRequest()
{
// 读取请求行和报头
if((!RecvHttpRequestLine()) && (!RecvHttpRequestHeader())){
ParseHttpRequestLine();// 解析请求行
ParseHttpRequestHeader();// 解析请求报头
RecvHttpRequestBody();// 读取请求正文
}
}
读取请求行
利用工具类中的ReadLine()函数将请求行读入到请求类中
bool RecvHttpRequestLine()
{
auto& line = http_request.request_line;
if(Util::ReadLine(sock,line) > 0){
line.resize(line.size()-1);// 去除行尾 \n
LOG(INFO,http_request.request_line);// 记录日志
}
else{
stop = true;// 标记读取失败
}
return stop;
}
读取请求报头
按行为单位,将读取到的报头属性插入到请求报头中。
bool RecvHttpRequestHeader()
{
std::string line;
while(line!="\n"){
line.clear();// 读取前清空line
if(Util::ReadLine(sock,line) <= 0){// 按行读取
stop = true;
break;
}
if(line == "\n"){// 读取到空行,请求报头已读完
http_request.blank = line;
break;
}
line.resize(line.size()-1);// 去除最后的'\n'
http_request.request_header.push_back(line);// 将读取到的报头属性尾插入请求报头中
LOG(INFO,line);
}
return stop;
}
解析请求行
请求行由 请求方法,请求URI,HTTP协议版本 构成,它们中间由空格隔开,如下图
解析请求行就是将字符串按空格分割。
将字符串按空格分割我们可以调用库函数中的stringstream
stringstream使用示例:
#include<iostream>
#include<string>
#include<sstream>
int main()
{
std::string msg = "GET /a/b/c.html http/1.0";
std::string method;
std::string uri;
std::string version;
std::stringstream ss(msg);
ss >> method >> uri >> version;
std::cout << method << std::endl;
std::cout << uri << std::endl;
std::cout << version << std::endl;
}
运行结果
解析请求行代码
void ParseHttpRequestLine()
{
auto &line = http_request.request_line;// 获取请求行
std::stringstream ss(line);
ss >> http_request.method >> http_request.uri >> http_request.version;
auto &method = http_request.method;// 获取请求行中的请求方法
std::transform(method.begin(),method.end(),method.begin(),::toupper);// 字母转大写,方便后文使用
}
解析请求报头
请求报头中包含了请求的各种信息,它们都是以 属性名:属性信息 的形式存放在vector容器中,为了方便找到请求报头中的信息,我们需要将请求报头中的每种属性拆分成 (属性名,属性信息)键值对存放在unordered_map中。
报头中属性和属性名间用 :隔开如下图
为此,我们把 :当成分隔符,将属性名和属性信息分开
#define SEP ": "
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});
}
}
}
读取请求正文
请求正文不一定存在,如果请求方法为GET,那么请求正文被设置为空,即GET方法不需要读取请求正文。如果请求方法为POST,请求正文可以存在,通过请求报头中的Content-Length获取请求正文的大小,但是Content-Length为0时,请求正文不存在。
// 判断是否需要读取请求正文
bool IsNeedRecvHttpRequestBody()
{
auto &method = http_request.method;
if(method == "POST"){// 请求方法为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;
}
// 读取请求正文
bool RecvHttpRequestBody()
{
if(IsNeedRecvHttpRequestBody()){// 判断是否需要读取请求正文
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{
stop = true;
break;
}
}
LOG(INFO, body);
}
return stop;
}
构建响应预处理
浏览器给服务器发送http请求的目的是让服务器完成某种任务,或想访问服务器上的资源。当服务器收到浏览器的http请求后,会根据请求行中的URI去在本地寻找资源处理问题,问题处理完后将处理结果构建成一个http响应报头返回给浏览器。
- 构建响应前先要确定资源的路径,这就需要我们去解析请求行中的URI。
不同请求方法下的URI格式是不同的,GET方法中,URI路径和参数间用?隔开,而POST方法中,URI只有路径。
- 获得路径后,就要去路径下寻找资源了。访问路径前,我们需要对路径做修饰,在其前面添加wwwroot目录。因为我们的资源目录是wwwroot,它和http进程在同一目录下,所以我们的http进程是可以通过相对路径去访问wwwroot目录。例如请求的路径是/test_cgi,修饰后变为wwwroot/test_cgi,此时http进程就会去wwwroot目录下寻找test_cgi。
- 寻找资源又会有4种情况
1)资源不存在。此时我们将错误码设置为404,也就是资源不存在。
2)资源是一个文件。我们需要记录文件的大小,将文件名添加到路径即可。
3)资源是一个可执行程序。我们需要标记cgi为真,表示需要cgi处理。
4)资源是一个目录。我们会在路径中添加目录下的默认文件index.html(在设计wwwroot资源时,我们会在每个目录下添加默认文件index.html)。
那么如何获取文件属性呢?我们利用 stat 函数
stat() 函数
功能: 查看一个文件是否存在,并将文件的属性存放在struct stat变量中。
返回值: 成功返回0,失败返回-1
struct stat
其中st_size属性是查看文件的大小,以字节为单位,st_mode存储了文件的类型和权限。
S_ISDIR (st_mode) 是一个宏定义,可以判断文件是否为目录。
st.st_mode&S_IXGRP,st.st_mode&S_IXOTH,st.st_mode&S_IXUSR 分别判断文件所属组,文件的其他人,文件所属人是否具有可执行权限,如果其中有一个为真,那么该文件具有可执行权限。
-
此外我们还需从请求行中获取文件名的后缀,如果获取失败默认为html。
-
GET方法和POST方法的区别
- GET方法从浏览器传参数给http服务器时,是需要将参数跟到URI后面的
- POST方法从浏览器传参数给http服务器时,是需要将参数放放到请求正文中的
- GET方法,如果没有传参,http按照一般的方式进行,返回资源即可
- GET方法,如果有参数传入,http就需要按照CGI方式处理参数,并将执行结果(期望资源)返回给浏览器
- POST方法,一般都需要使用CGI方式来进行处理
总体代码如下
#define NOT_FOUND 404
#define BAD_REQUEST 400
// 构建响应
void BuildHttpResponse()
{
auto &code = http_response.status_code;// 状态码
std::string _path;// 路径
struct stat st;
int size = 0;// 记录文件大小
std::size_t found = 0;
if(http_request.method != "GET" && http_request.method != "POST"){// 方法不存在设置错误码为400
// 非法请求
LOG(WARNING,"method is not right");
code = BAD_REQUEST;// 设置错误码
goto END;
}
if(http_request.method == "GET"){
// 将获取URI中的路径和参数
size_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;// 需要cgi处理
}
else{
http_request.path = http_request.uri;
}
}
else if(http_request.method == "POST"){
// POST
http_request.cgi = true;// 需要cgi处理
http_request.path = http_request.uri;
}
_path = http_request.path;
// 构建资源路径
http_request.path = WEB_ROOT;// 在路径前添加wwwroot目录
http_request.path += _path;
if(http_request.path[http_request.path.size()-1] == '/'){
http_request.path += HOME_PAGE;// 添加当前目录下的html网页
}
if(stat(http_request.path.c_str(),&st)==0){
//资源是目录
if(S_ISDIR(st.st_mode)){
// 请求的资源是一个目录需要做相关处理
// 注意,前文中我们把最后的'/'删去了,这里要添加上来
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)){
// cgi处理
http_request.cgi = true;
}
http_request.size = st.st_size;// 记录打开文件的大小
}
else{
//资源不存在
LOG(WARNING,http_request.path + "Not Found");
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);
}
if(http_request.cgi){
code = ProcessCgi();// 执行目标程序,拿到结果:http_response.response_body;
}
else{
code = ProcessNonCgi();// 简单的网页返回,返回静态网页(打开即可)
}
END:
BuildHttpResponseHelper();// 构建响应报文
}
返回网页
网页本质是一个超文本文件,也就是我们的前端代码,当返回这些代码给浏览器的时候,浏览器就会解析成一个网页。
所以浏览器访问的资源是文件时,直接打开文件并将文件描述符添加到响应报头中。
// 非cgi处理
int ProcessNonCgi()
{
http_response.fd = open(http_request.path.c_str(),O_RDONLY);// 以只读形式打开文件
if(http_response.fd >= 0){// 打开成功
LOG(INFO,http_request.path + " open success!");
return OK;// 打开失败
}
return 404;
}
CGI机制
基本概念
公共网关接口(Common Gateway Interface,CGI)是Web 服务器运行时外部程序的规范,按CGI 编写的程序可以扩展服务器功能。CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,有着不可替代的重要地位。CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的过程。
CGI程序就是在服务器上的可执行程序,当浏览器访问的资源是一个可执行程序时,http进程就会创建一个子进程,通过execl函数将可执行程序替换为子进程。然后http进程会把参数传递给子进程,子进程将参数处理结果返回给http进程,http进程再通过网络返回给浏览器。这种http进程去调用CGI处理数据的方式就叫做CGI机制。
CGI机制使用
CGI机制过程如下图
当触发了cgi机制时,http需要创建子进程,这样在程序替换时可以用子进程进行替换。父进程和子进程间需要进行通信,父进程传递参数给子进程,而子进程通过CGI程序得到的结果需要返回给父进程。这里我们创建匿名管道方便父子进程间通信,因为管道数据传输是单向的,所以我们需要创建两条匿名管道。
对于子进程来说,子进程被程序替换后,它拿到两个管道的文件描述符的数据也会被替换掉,此时子进程不知道管道的文件描述符,也就无法与父进程通信,为了解决这个问题我们可以将管道重定向到0号文件描述符和1号文件描述符,这样父子进程就可以通过 cin 和 cout 进行交流。
http进程给子进程传参的时候,GET请求和POST请求传参方式是不同的。
- 如果是GET方法,传递参数给子进程是通过设置环境变量的方式给子进程,因为URI中参数是有大小限制,一般都不会太长,并且程序替换,只替换进程的代码和数据,不会替换环境变量,因此在子进程被execl之前,提前设置一个PATAMETER的环境变量。
- 如果是POST方法,传递参数给子进程是通过管道的方式给子进程,因为POST中的请求正文的参数是没有限制的。但是子进程怎么知道要在管道中读取多少个字符呢?此时就需要将通过设置一个环境变量Content-Length来标识参数的大小,让子进程知道需要从管道中读取多少个字符。
- 为了知道是GET请求还是POST请求,我们需要设置一个环境变量METHOD来标识请求方法。
代码实现:
// CGI处理
int ProcessCgi()
{
int code = OK;
LOG(INFO,"process cgi mthod!");
// 父进程数据
auto &method = http_request.method;// 请求方法
auto &query_string = http_request.query_string; // 请求报头
auto &body_text = http_request.request_body; // 请求正文
auto &bin = http_request.path;// 请求路径
int content_length = http_request.content_length;// 请求正文长度
auto &response_body = http_response.response_body;// 响应正文
std::string query_string_env;
std::string method_env;
std::string content_length_env;
int input[2];
int output[2];
// 创建双向管道
if(pipe(input) < 0){
// 创建失败
LOG(ERROR,"pipe input error");
code = SERVER_ERROR;
return code;
}
if(pipe(output) < 0){
LOG(ERROR,"pipe output error");
code = SERVER_ERROR;
return code;
}
// 创建子进程
pid_t pid = fork();
if(pid == 0){// 子进程
close(input[0]);
close(output[1]);
// 站在子进程角度
// input[1]: 写出
// output[0]: 读入
method_env = "METHOD=";
method_env += method;
putenv((char*)method_env.c_str());// 增加环境变量
// 环境变量不会被env给替换掉
if(method == "GET"){
query_string_env = "QUERY_STRING=";
query_string_env += query_string;
putenv((char*)query_string_env.c_str());
LOG(INFO,"Get Method,Add Query_String Env");
}
else if(method == "POST"){
content_length_env = "CONTENT_LENGTH=";
content_length_env += std::to_string(content_length);
putenv((char*)content_length_env.c_str());
LOG(INFO,"Post Method,Add Content-Length Env");
}
else{
}
// 重定向
dup2(output[0],0);// 重定向到标准输入
dup2(input[1],1);// 重定向到标准输出
// 替换成功之后,目标子进程如何得知,对应读写文件描述符是多少呢?不需要,只要读0,写1即可
execl(bin.c_str(),bin.c_str(),nullptr);// 进程替换
exit(1);
}
else if(pid < 0){// 创建子进程失败
LOG(ERROR,"fork error!");
return 404;
}
else{// 父进程
close(input[1]);
close(output[0]);
// 站在父进程角度
// input[0]: 读入
// output[1]: 写出
if(method == "POST"){
// 将请求报头读入管道
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 = waitpid(pid,&status,0);// 等待子进程
if(ret == pid){
if(WIFEXITED(status)){// 非0 表明进程正常结束
if(WEXITSTATUS(status) == 0){// 0表示进程正常终止
code = OK;
}
else{
code = SERVER_ERROR;
}
}
else{
code = SERVER_ERROR;
}
}
close(input[0]);
close(output[1]);
}
return OK;
}
CGI程序
这里我们设计一个简单的CGI程序,计算两个数的加减乘除。
#include <iostream>
#include <cstdlib>
#include <unistd.h>
bool GetQueryString(std::string &query_string)
{
bool result = false;
std::string method = getenv("METHOD");
if(method == "GET"){
query_string = getenv("QUERY_STRING");
result = true;
}
else if(method == "POST"){
int content_length = atoi(getenv("CONTENT_LENGTH"));
char c = 0;
while(content_length){
read(0,&c,1);
query_string.push_back(c);
content_length--;
}
result = true;
}
else{
result = false;
}
return result;
}
void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{
auto pos = in.find(sep);
if(std::string::npos != pos){
out1 = in.substr(0,pos);
out2 = in.substr(pos+sep.size());
}
}
int main()
{
std::string query_string;
GetQueryString(query_string);
// a=100&b=200
std::string str1;
std::string str2;
CutString(query_string,"&",str1,str2);
std::string name1;
std::string value1;
CutString(str1,"=",name1,value1);
std::string name2;
std::string value2;
CutString(str2,"=",name2,value2);
std::cout << name1 << " : " << value1 << std::endl;
std::cout << name2 << " : " << value2 << std::endl;
std::cerr << name1 << " : " << value1 << std::endl;
std::cerr << name2 << " : " << value2 << std::endl;
int x = atoi(value1.c_str());
int y = atoi(value2.c_str());
//可能向进行某种计算(计算,搜索,登陆等),想进行某种存储(注册)
std::cout << "<html>";
std::cout << "<head><meta charset=\"utf-8\"></head>";
std::cout << "<body>";
std::cout << "<h3> " << value1 << " + " << value2 << " = "<< x+y << "</h3>";
std::cout << "<h3> " << value1 << " - " << value2 << " = "<< x-y << "</h3>";
std::cout << "<h3> " << value1 << " * " << value2 << " = "<< x*y << "</h3>";
std::cout << "<h3> " << value1 << " / " << value2 << " = "<< x/y << "</h3>";
std::cout << "</body>";
std::cout << "</html>";
return 0;
}
构建响应
响应报文
构建响应行
如上图所示,响应行由协议版本,状态码和状态描述组成,两两间用空格隔开。
// 将状态码转换为字符串
static std::string Code2Desc(int code)
{
std::string desc;
switch(code){
case 200:
desc = "OK";
break;
case 404:
desc = "Not Found";
break;
default:
break;
}
return desc;
}
// 构建响应报文
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 += LINE_END;
// 构建响应报文,可能包括响应报头
std::string path = WEB_ROOT;
path += "/";
switch(code){
case OK:
BuildOkResponse();
break;
case NOT_FOUND:
path += PAGE_404;
HandlerError(path);
break;
case BAD_REQUEST:
path += PAGE_404;
HandlerError(path);
break;
case SERVER_ERROR:
path += PAGE_404;
HandlerError(path);
break;
default:
break;
}
}
构建响应报头
构建OK响应报头
构建响应报头需要文件类型(Content-Type),我们可以建立文件后缀和文件类型的映射,通过文件后缀返回对应的文件类型。
// 转换后缀
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"},
{".xml","application/xml"}
};
auto iter = suffix2desc.find(suffix);
if(iter != suffix2desc.end()){
return iter -> second;
}
return "text/html";
}
此外响应报头还需要Content-Length,如果是cgi处理,cgi处理时会将结果添加到响应正文中,content-length 就是响应正文的大小。如果是非cgi处理,content-length 是打开文件的大小。不要忘记在每行结尾要添加换行符、
#define LINE_END "\r\n"
// 构建OK响应报头
void BuildOkResponse()
{
std::string line = "Content-Type: ";
line += Suffix2Desc(http_request.suffix);// 添加文件类型
line += LINE_END;// 添加/r/n
http_response.response_header.push_back(line);// 添加到响应报头
line = "Content-Length: ";
if(http_request.cgi){
line += std::to_string(http_response.response_body.size());// cgi的结果的大小
}
else{
line += std::to_string(http_request.size);// 打开文件的大小
}
line += LINE_END;
http_response.response_header.push_back(line);
}
错误码响应报头
我们还需设置一个错误码响应报头,该报头打开对应错误码网页,并将错误码网页的信息添加到响应报头中。
#define LINE_END "\r\n"
// 构建错误码的响应报头
void HandlerError(std::string page)
{
std::cout<<"debug: "<<page<<std::endl;
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);
http_request.size = st.st_size;// 更新打开页面的信息
std::string line = "Content-Type: text/html";// 所以错误页面都是文本文件,文件类型都为text/html
line += LINE_END;
http_response.response_header.push_back(line);
line = "Content-Length: ";// 错误页面文本文件的大小
line += std::to_string(st.st_size);
line += LINE_END;
http_response.response_header.push_back(line);
}
}
发送响应
构建完响应后,我们需要将响应中响应行,响应报头,响应空行,响应正文依次发送给浏览器。
我们非cgi处理时用的是sendfile()函数,sendfile()函数在两个文件描述符之间传递数据完全在内核中操作,从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,被称为零拷贝。
senfile()函数原型
ssize_t senfile(int out_fd,int in_fd,off_t* offset,size_t count);
参数:
- out_fd参数是待写入内容的文件描述符
- in_fd参数是待读出内容的文件描述符
- offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位
- count参数指定文件描述符in_fd和out_fd之间传输的字节数。
// 发送响应
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);// 响应空行
if(http_request.cgi){
// 如果是cgi处理,直接发送响应正文给浏览器
auto &response_body = http_response.response_body;
size_t size = 0;
size_t 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{
// 非cgi处理,将打开文件的内容发送给浏览器。
sendfile(sock,http_response.fd,nullptr,http_request.size);
close(http_response.fd);
}
}
HTTP服务器
HTTP服务器的功能是等待客户端连接,连接成功后将客户端请求添加到线程中的任务队列中。
#pragma once
#include<iostream>
#include<pthread.h>
#include<signal.h>
#include"Log.hpp"
#include"TcpServer.hpp"
#include"Protocol.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"
#define PORT 8081
class HttpServer{
private:
int port;
bool stop;
public:
HttpServer(int _port = PORT):port(_port),stop(false)
{}
void InitServer()
{
// 如果是服务器正往sock中写入,而浏览器将连接关掉后,那么浏览器就会收到一个SIGPIPE的信号,
// 此时服务器就会崩掉,因此我们在初始化服务器的时候需要忽略该SIGPIPE信号。
signal(SIGPIPE,SIG_IGN);
//tcp_server = TcpServer::getinstance(port);
}
void Loop()
{
TcpServer *tsvr = TcpServer::getinstance(port);
LOG(INFO,"Loop begin");
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()
{}
};
引入线程池
为了避免同一时间有大量链接过来导致服务器内部线程暴增,从而引起服务器效率降低或挂掉,我们引入了线程池。线程池节省链接请求到来时,创建线程的时间成本,同时也让服务器的效率在一个恒定的稳定区间内。
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include "Task.hpp"
#include "Log.hpp"
#define NUM 6
class ThreadPool{
private:
int num;
bool stop;
std::queue<Task> task_queue;// 任务队列
pthread_mutex_t lock;
pthread_cond_t cond;
ThreadPool(int _num = NUM):num(_num),stop(false)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&cond,nullptr);
}
ThreadPool(const ThreadPool &){}// 防拷贝
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 IsStop()
{
return stop;
}
bool TaskQueueIsEmpty()
{
return task_queue.size()==0?true:false;
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void ThreadWait()
{
pthread_cond_wait(&cond,&lock);
}
void ThreadWakeup()
{
pthread_cond_signal(&cond);
}
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 tid;
if(pthread_create(&tid,nullptr,ThreadRoutine,this)!=0){
LOG(FATAL,"create thread pool error!");
return false;
}
}
LOG(INFO,"create thread success!");
return true;
}
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;
项目流程
整体框架
- 启动服务端,对http服务器初始化
- 等待客户端连接,连接成功后将客户端请求添加到线程中的任务队列中。
任务类
- 在线程池中处理任务队列
4.处理http请求
处理http请求
- 整体概括
- 读取请求行
- 读取请求报头
- 解析请求行
- 解析请求报头
- 读取请求正文
构建响应
- 提取资源路径
- 获取资源路径
- 非cgi处理
- cgi处理
准备操作:创建双向管道方便程序替换后子进程和父进程间的交流
子进程和cgi处理进行程序替换
父进程将请求正文读入管道,并从管道中获取响应主体
- 构建响应报文
构建响应报头行
状态码转字符串
构建响应报头,这里只有两条资源 文件类型 和 文件大小
文件后缀转换
返回响应
总结
该项目涉及了很多知识点,博主花了将近两个月的时间,断断续续完成了该项目。下面是该项目的全部代码