博客介绍:运用之前学过的各种知识 自己独立做出一个HTTP服务器
http协议被广泛使用 从移动端 pc端浏览器 http协议无疑是打开互联网应用窗口的重要协议
http在网络应用层中的地位不可撼动 是能准确区分前后台的重要协议
虽然说现在最常用的是https协议 但是我们学会了http协议之后对于https的学习也能更加轻松
对http协议的理论学习 从零开始完成web服务器开发 坐拥下三层协议 从技术到应用 让网络难点无处遁形
采用C/S模型 编写支持中小型应用的http 并结合mysql 理解常见互联网应用行为 做完该项目 我们可以从技术上完全理解从你上网开始 到关闭浏览器的所有操作中的技术细节
关于此次WEB服务器项目 我们主要用到的技术有
此次项目使用的开发环境是 LinuxCentos7
使用的工具有 gcc/g++/gdb + C/C++
WWW是环球信息网的缩写 (亦作“Web”、“WWW”、“‘W3’”,英文全称为“World Wide Web”) 中文名字为“万维网”,"环球网"等,常简称为Web。
它分为web服务器和web客户端
WWW可以让Web客户端(常用浏览器)访问浏览Web服务器上的页面 是一个由许多互相链接的超文本组成的系统 通过互联网访问
在这个大系统中 我们将每一个有用的事务都称为一个资源 并且由一个全局 统一资源标识符 (URI)标识 这些资源通过超文本传输协议 (HTTP协议) 传送给用户 而后者通过点击连接来获取资源
从之前网络部分的学习我们知道 TCP/IP协议下的网络模型可以分为四层
从作用上分类 我们可以将网络四层模型分为两部分
从细节上看
发送端自上而下会经过 应用层 传输层 网络层 数据链路层 其中经过每一层都会进行添加报头的操作来保证数据正确的送达对面
接收端自下而上的会对于这些数据进行解包 所以说接收端和发送端 我们可以认为他们同层之间看到的数据是相同的即同层之间可以看作是能够直接通信
tcp和ip协议我们在网络部分已经深入了解学习过了 那么什么是dns协议呢?
我们在ping www.baidu.com的时候 可以发现下方自动给我们转化为了一个ip地址
事实上这个ip地址就是百度服务器的公网ip 而dns的作用就是将域名转化为ip地址
为什么要有dns协议的存在呢?
因为我们人类更擅长记住一些有意义的字符串而不是数字 域名和dns本质是为了优化用户的使用体验的
目前主流的服务器使用的是http/1.1版本 而我们此次项目使用http/1.0来进行讲解 同时我们还会对比1.0和1.1版本的各种区别
此外我们此次项目只会写服务器 客户端使用浏览器代替
HTTP协议有个特点就是C/S模式 (客户端服务器模式)
客户端通过一些方法(get post等)向服务器发送请求 之后服务器接收到请求之后发送响应
http协议有以下四个特点
http协议的无连接体现在哪里
http协议的无连接是对比tcp协议的连接而言的
http协议它本身对于连接没有概念 它只知道将自己要发送的数据交给下层协议 然后下层协议就会将数据发送到对端
http协议的无状态体现在哪里
http协议的无状态体现在它并不会记得自己发送或者接受过任何的数据
但是同学们读到这里可能会产生一个疑问 那么为什么我在浏览器上登录一个网站之后这个网站就记得我了呢?
这实际上是由浏览器的cookie和session机制实现的 具体的原理可以参考这篇博客
cookie和session
URI是一种抽象的 更高层次的一种统一资源标识符 而URL和URN则是一种具体的标识符
URL和URN是URI的子集 不过我们使用URN并不多
简单来说 URI和URL的主要区别是
URL(Uniform Resource Lacator)叫做统一资源定位符也就是我们通常所说的网址
其中服务器地址就是域名对应着我们的IP地址 带层次的文件路径实际上就是我们Linux中的路径
接下来我们来较为全面的认识下上面URL
一、协议方案名
http://
表示的是协议名称 表示请求时需要使用的协议 通常使用的是HTTP协议或安全协议HTTPS
HTTPS是以安全为目标的HTTP通道 在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性
二、登录信息
usr:pass
表示的是登录认证信息 包括登录用户的用户名和密码
虽然登录认证信息可以在URL中体现出来 但绝大多数URL的这个字段都是被省略的 因为登录信息可以通过其他方案交付给服务器
三、服务器地址
www.example.jp表示的是服务器地址 也叫做域名
HTTP的请求协议格式如下:
我们可以看到HTTP请求由四部分组成
其中前面三部分是由HTTP协议自带的 而请求正文则是用户的相关信息和数据 如果说用户没有信息要上传给服务器 此时正文则为空
如何将HTTP请求的报头与有效载荷进行分离?
首先我们要明白哪里是报头哪里是有效载荷
请求报头:请求行+请求报头
有效载荷:请求正文
细心的同学就可以发现了 事实上报头和有效载荷之间隔着一个空行
如果我们将整个http协议想象成一个线性的结构 每一行都是使用\n
来进行分隔的 那么如果我们连续读取到两个\n
的话就说明报头读取完毕了开始读取有效载荷
获取浏览器的HTTP请求
在网络协议栈中 应用层的下一层叫做传输层 而HTTP协议底层通常使用的传输层协议是TCP协议
因此我们可以用套接字编写一个TCP服务器 然后启动浏览器访问我们的这个服务器
由于我们的服务器是直接用TCP套接字读取浏览器发来的HTTP请求 此时在服务端没有应用层对这个HTTP请求进行过任何解析
因此我们可以直接将浏览器发来的HTTP请求进行打印输出 此时就能看到HTTP请求的基本构成
HTTP响应协议格式如下:
HTTP响应由以下四部分组成:
如何将HTTP响应的报头与有效载荷进行分离?
对于HTTP响应来讲 这里的状态行和响应报头就是HTTP的报头信息 而这里的响应正文实际就是HTTP的有效载荷
而报头信息和响应正文之间我们使用换行符来进行分隔
当客户端收到一个HTTP响应后 就可以按行进行读取 如果读取到空行则说明报头已经读取完毕
再介绍其他的格式细节之前我们先来写上一部分的代码
我们首先再Linux服务器上创建一个项目目录 之后在项目目录里面创建一个hpp文件
这里有同学可能会有疑惑 为什么要写的是一个HTTP的服务器 这里确要先写一个TcpSever呢
这是因为Http是基于Tcp的 我们首先要保证信息的传输之后再设计http
为什么文件格式要是hpp
hpp格式的C++文件通常可以将类的声明和实现放在一起 比较适合存在于开源项目中
我们设计一个TcpSever类只需要提供一个端口就可以了 因为操作系统会给我们自动分配一个ip 需要注意的是因为我们这里使用的是云服务器 最好不要使用云服务器的公网ip和私有ip 否则有可能会出现对端主机连接不上的情况(ip不是真正的公网ip)
下面是TcpServer的代码
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#define PORT 8081
#define BackLog 5
class TcpServer{
private:
int _port;
int _listen_sock;
static TcpServer* svr;
private:
TcpServer()
:_port(PORT),
_listen_sock(-1)
{}
W> TcpServer(const TcpServer &s)
{}
TcpServer(int port)
:_port(port),
_listen_sock(-1)
{}
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->InitSever();
}
pthread_mutex_unlock(&lock);
}
return svr;
}
void InitSever()
{
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);
}
}
int Sock()
{
return _listen_sock;
}
~TcpServer()
{
}
};
TcpServer* TcpServer::svr = nullptr;
上面是我们使用单例模式设计出来的一个TcpSever 创建Listen套接字 绑定 监听都是网络部分很常见的套路 封装即可
这部分代码其实在网络部分就写过很多遍了 这里就不过多赘述
#include
#include
#include
#include "HttpSever.hpp"
static void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " port" << std::endl;
}
int main(int argc , char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(4);
}
int port = atoi(argv[1]);
std::shared_ptr<HttpSever> http_server(new HttpSever(port));
http_server->InitSever();
http_server->Loop();
for(;;)
{
;
}
return 0;
}
bin=httpsecer
cc=g++
LD_FLAGS=-std=c++11 -lpthread
src=main.cc
$(bin):$(src)
$(cc) -o $@ $^ $(LD_FLAGS)
.PHONY:clean
clean:
rm $(bin)
#pragma once
#include
#include
#include "TcpServer.hpp"
#include "Prorocol.hpp"
class HttpSever{
private:
bool _stop; // 是否在运行
int _port;
TcpServer* _tcp_server;
public:
HttpSever(int port = 8081)
W> :_port(port),
_stop(false),
_tcp_server(nullptr)
{}
void InitSever()
{
_tcp_server = TcpServer::getinstance(_port);
}
void Loop()
{
int listen_sock = _tcp_server->Sock();
while(!_stop)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock ,(struct sockaddr*)&peer , &len);
if (sock < 0)
{
continue; // 获取失败了不暂停
}
int* msock = new int(sock);
pthread_t tid;
pthread_create(&tid , nullptr , Entrance::HandlerRequest , msock);
pthread_detach(tid);
}
}
~HttpSever()
{}
};
#pragma once
#include
#include
class Entrance
{
public:
static void* HandlerRequest(void* _sock)
{
int sock =*(int*)_sock;
delete (int*)_sock;
std::cout << "get a new link ..." << sock << std::endl;
close(sock);
return nullptr;
}
};
我们写完上面的代码之后编译运行 之后使用浏览器来访问我们服务器的8081端口
我们可以发现这样的现象
服务器一直在打开4和5套接字 这是为什么呢?
因为浏览器不知道自己的请求有没有被服务器接收到 所以说浏览器一直重新发送请求 这也就导致了服务器一直打开新的套接字
为什么文件描述符是从4开始的呢?
因为C++文件默认会打开 0 1 2 三个文件描述符 此外监听套接字也会占用一个文件描述符
此时我们的服务器就已经可以跑起来了 接下来我们就重点完成Prorocol里面的内容 接收到请求之后应该怎么做
我们首先让协议打印出我们接收的请求 该段代码如下
#ifndef DEBUG
#define DEBUG
char buffer[4096];
recv(sock , buffer , sizeof(buffer) , 0);
std::cout << "--------------begin-------------" << std::endl;
std::cout << buffer << std::endl;
std::cout << "-------------- end -------------" << std::endl;
#endif
之后我们重新编译运行
此时我们就可以发现 我们的服务器可以打印浏览器发送过来的一些请求报头
接下来我们逐段分析下这些请求报头
我们可以看到在请求报头的第一行GET方法后面有个 \ 标志 这个标志和我们的Linux服务器根目录的标志十分相似 那么它到底是不是Linux服务器的根目录呢?
我们的回答是 不一定
这个路径通常不是我们Linux服务器的根目录 而是由http服务器设置的一个WEB根目录
这个WEB根目录就是Linux下一个特定的路径
其实我们HTTP处理请求的过程无非就是三步
我们目前仍然出于处理请求的阶段 有同学可能会有疑问 我们这里不是将数据全部都读取完毕了嘛?
其实我们这里读取的数据是不准确的
char buffer[4096];
recv(sock , buffer , sizeof(buffer) , 0);
std::cout << "--------------begin-------------" << std::endl;
std::cout << buffer << std::endl;
std::cout << "-------------- end -------------" << std::endl;
我们读取的代码是这样子 它不管客户端每次发送过来多少的请求 我们直接将它放在一个4096字节的数组里面
但是有没有一种可能 客户端一次性会发来多个请求呢? 那么这样子我们还是一次性读取4096个字节 是不是就会发生一个粘包的问题啊 那么这个时候我们就要想办法解决这个问题
我们从它的请求格式来分析
我们观察上图可以发现 实际上HTTP协议的报头是按照行来分隔的 并且在报头和正文中间有一个空行作为间隔 所以说我们就能很简单的将报头和正文分隔开
但是这里就会出现一个问题 那就是每个平台或是浏览器他们分隔一行的方式可能不同 具体有下面三种
所以说我们还要自己主动实现一个类来实现分隔行的问题
代码如下
#pragma once
#include
#include
#include
#include
// ¹¤¾ßÀà
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);
if (s > 0)
{
if (ch == '\r')
{
// ת»¯Îª /n
recv(sock , &ch , 1 , MSG_PEEK);
if (ch == '\n')
{
recv(sock , &ch , 1 , 0);
}
else
{
ch = '\n';
}
}
// 1. ÆÕͨ×Ö·û
// 2 \n
out.push_back(ch);
}
else if (s == 0)
{
return 0;
}
else
{
return -1;
}
}
return out.size();
}
};
简单解释下上面的代码 它的作用是将每一行的结束标识符全部统一为\n
因为我们行结束的标志只有三种 所以我们可以做以下区分
在能够读取单行之后我们可以开始获取一个完整的Http请求了
我们将实现获取完整Http请求这个步骤放在Prorocol这个文件里面方便管理
具体的实现思想是
具体代码如下
#pragma once
#include
#include
#include
#include
#include
#include
#include "Util.hpp"
class HttpRequest{
public:
std::string _request_line;
std::vector<std::string> _request_header;
std::string _blank;
std::string _request_body;
};
class HttpResponse{
public:
std::string _response_line;
std::vector<std::string> _response_header;
std::string _blank;
std::string _response_body;
};
class EndPoint{
private:
int _sock;
HttpRequest http_request;
HttpResponse http_response;
private:
void RecvRequestLine()
{
Util::ReadLine(_sock ,http_request._request_line);
}
public:
EndPoint(int sock)
:_sock(sock)
{}
void RcvRequest()
{}
void ParseRequest()
{}
void SendResponse()
{}
void BuildResponse()
{}
~EndPoint()
{
close(_sock);
}
};
class Entrance
{
public:
static void* HandlerRequest(void* _sock)
{
int sock =*(int*)_sock;
delete (int*)_sock;
std::cout << "get a new link ..." << sock << std::endl;
#ifdef DEBUG
char buffer[4096];
recv(sock , buffer , sizeof(buffer) , 0);
std::cout << "--------------begin-------------" << std::endl;
std::cout << buffer << std::endl;
std::cout << "-------------- end -------------" << std::endl;
#else
EndPoint* ep = new EndPoint(sock);
ep->RcvRequest();
ep->ParseRequest();
ep->BuildResponse();
ep->SendResponse();
delete ep;
#endif
return nullptr;
}
};
为什么要写日志
这个问题也可以是为什么要有日志
日志可以帮助我们了解错误发生的原因和程序运行的状态 从而可以帮助我们更好的去排除错误或者是优化我们的程序
我们要写的日志格式是什么样的
格式如下
分别介绍下上面各个信息
日志级别
日志也是分级别的 这些级别让我们更好的辨别要如何处理这条日志
级别一般分为下面几种
时间戳
我们可以直接使用c语言函数来获取时间戳
c语言中的time函数能够返回给我们一个时间戳
函数原型如下
time(nullptr)
使用时我们只需要传入参数nullptr即可 (如果不是C++11以后的版本传入NULL)
日志信息
告诉我们错误和风险提示等信息
错误文件名称和行数
在c语言中预先定义了__FILE__ 以及 __LINE__
我们直接使用它们来获取错误文件名称和行数即可
代码如下
#pragma once
#include
#include
#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;
}
之后我们只需要将LOG宏使用起来即可
一个完整的请求报文如下
void RecvRequestLine()
{
Util::ReadLine(_sock ,http_request._request_line);
}
那么 我们应该如何读取行数不确定的请求报头呢?
我们现在知道的有两点
有了上面这两点之后我们就能很轻松的读取完所有的请求报头
只需要按行读取并且读取到\n结束即可
void RecvRequestLine()
{
Util::ReadLine(_sock ,http_request._request_line);
}
void RecvRequestHeader()
{
std::string line;
while(true)
{
line.clear();
Util::ReadLine(_sock , line);
if (line == "\n")
{
break;
}
line.resize(line.size()-1); // remove \n
http_request._request_header.push_back(line);
}
if (line == "\n")
{
http_request._blank = line;
}
}
读取到请求行和请求报头之后我们就开始解析它们
首先是请求行 格式如下
std::string method;
std::string uri;
std::string version;
所以说我们只需要按照空格作为分隔符 将请求行的代码分隔为三部分即可
而我们这里推荐使用stringstream 这个类中重载了流插入运算符默认会按照空格来进行分隔给字符串赋值 用法代码示例如下
void ParseHttpRequestLine()
{
auto& line = http_request._request_line;
std::stringstream ss(line);
ss >> http_request.method >> http_request.uri >> http_request.version;
}
解释下上面这段代码 我们首先使用line拷贝构造一个stringstream对象 之后让这个对象分别以空格为分隔符将它的内容赋值给http_request.method http_request.uri http_request.version
经过观察我们不难发现 这里其实就是一个键值对结构 所以说我们使用哈希表来存储即可
有关于哈希表部分知识不理解的同学可以参考我的这篇博客
unordered_map
但是首先我们需要写一个方法让一个字符串按照冒号分隔为两部分
具体实现方法如下
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());
return true;
}
return false;
}
之后我们只需要遍历整个请求报头 一个个分割key和value之后插入哈希表中就可以
代码表示如下
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
for (auto& iter : http_request._request_header)
{
Util::CutString(iter , key , value , SEP);
http_request.header_kv.insert({key,value});
}
}
处理完上面的两个部分之后我们再回过来看请求报文的图
此时我们就面临两个问题了
关于第一个问题
一般来说我们的请求方法如果是GET 则一般没有正文
如果我们的请求方法是POST 则一般有正文
关于第二个问题
我们可以由请求报头中的 Content–legth字段来知晓
判断是否存在正文的代码如下
bool IsRecvBody()
{
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())
{
http_request.content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
读取正文代码如下
void RecvRequestBody()
{
if (IsRecvBody())
{
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;
}
}
}
}
至此 我们的请求和解析报文全部完成
在处理这个请求之前 我们首先要考虑这个请求是否是合法的请求
如果是非法的请求 我们应该怎么操作
假设我们现在的Http服务器只接收GET和POST方法的请求 其他的请求只给予一个错误的响应即可 (我们这里最开始默认为404 NOT FOUND)
代码如下
void BuildResponse()
{
if (http_request.method != "GET" && http_request.method != "POST")
{
}
}
对于响应报文来说 最重要的一部分就是我们的状态码了
HTTP的状态码如下:
编号 | 类别 | 意义 |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
其中我们要记住的有下面这几个
101 信息请求中
101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了
200 OK
这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行
301 永久重定向
比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上
此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站
302 307 临时重定向
从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站
403 权限不足
这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码
404 NOT FOUND
常见于资源消失不见(被删除或过期) 又或者说资源根本不存在
比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码
504 Bad Gateway
常见于服务器出现问题 和客户端无关
Redirection(重定向状态码)
除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码
重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向
临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址
如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站
而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站
其实目前从宏观的角度上来说 我们的上网行为可以分为两种
其中向浏览器上传数据的时候我们有两种方法
客户端为什么要将数据上传到服务器呢?
当然是为了让服务器对客户端传输上来的数据进行处理
这里根据GET和POST传参方式的不同 我们处理请求的方式也需要发生一点变化
由于POST方法传参是通过正文传递参数的 而正文我们已经获取到了 所以说不用关心
但是GET方法传参是通过url传参 而url在请求行中 需要我们特殊处理一下
处理的代码如下 其实就是复用了我们前面剪切字符串的功能函数
auto& code = http_response.response_code;
if (http_request.method != "GET" && http_request.method != "POST")
{
// waring request
code = NOT_FOUND;
goto END;
}
if (http_request.method == "GET")
{
size_t pos = http_request.uri.find("?");
if (pos != std::string::npos)
{
Util::CutString(http_request.uri,http_request.path, http_request.query_string , "?");
}
else
{
http_request.path = http_request.uri;
}
}
std::cout << "debug url: " << http_request.uri << std::endl;
std::cout << "debug path: " << http_request.path << std::endl;
std::cout << "debug query_string: " << http_request.query_string << std::endl;
问题一:资源是从哪里开始的
这个资源不一定是从根目录开始的
一般来说我们会自己指定一个web根目录 所有的资源都在这个web根目录当中
一般来说web根目录的名称是wwwroot如下
如果说访问我们服务器的客户端没有指明想要获取什么资源的话我们肯定不可能将web根目录下的所有资源全部发出去
所以说这个时候我们就要指定一个默认的资源 一般来说这个资源就是index.html
所以说此时我们的path就不能简单的是/a/b/c了
我们要在前面加上web根目录在Linux服务器中的定位 比如说像这样
wwwroot(web根目录的路径) 加上 /a/b/c
定位代码如下
#define WEB_ROOT "wwwroot/"
std::string _path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += _path;
std::cout << "debug: " << http_request.path << std::endl;
演示效果如下
我们前面也说过了 如果客户端请求的是Web根目录的话我们不可能将整个Web根目录的所有资源全部给他 所以说针对于请求web根目录的情况我们要做一些特殊处理
如果请求的是我们的web根目录我们就返回index.html页面给它
代码表示如下
#define HOME_PAGE "index.html"
http_request.path = WEB_ROOT;
http_request.path += _path;
if (http_request.path[http_request.path.size()-1] == '/')
{
http_request.path += HOME_PAGE;
}
std::cout << "debug: " << http_request.path << std::endl;
问题二 : 如何确认这个资源是存在的
我们首先使用百度来试验下 如果资源不存在会怎么样
可以发现 百度服务器直接给我们返回了一个404告知我们该资源不存在
所以说我们在返回给客户端请求之前需要确认一个资源是否存在
这里使用确认资源是否存在的函数是stat函数
stat函数
函数原型如下
int stat(const char* path , struct stat *buf)
参数说明:
返回值说明:
如果找到该文件返回0 如果没找到返回-1
struct stat st;
if (stat(http_request.path.c_str() , &st) == 0)
{
// exist
}
else
{
// not exist
code = NOT_FOUND;
goto END;
}
所有的目录中都有一个index.html嘛?
是的 因为一个目录中可能有着大量的网页资源 如果我们不设置一个默认的index.html则系统就不知道应该返回哪个资源给客户端了
在回答了问题二之后便会衍生出一个问题三
存在的资源就是可以读取的资源嘛?
请求的资源是目录
我们可以使用下面的方法来判断是否请求的资源是一个目录
S_ISDIR(st.st_mode)
S_ISDIR是一个宏 而st_mode则是stat结构体中的一个成员变量
使用这个宏我们就能够判断当前请求的资源是否是一个目录
如果是一个目录 我们前面介绍过 每个目录都会有一个index.html 所以我们直接在请求的路径上加上即可
请求的资源是可执行程序
一般来说 客户端请求可执行程序是被允许的
但是我们这里要对于这种情况做一下特殊处理
我们通过下面的方法来确定文件是否是一个可执行文件
if ((st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH))
{
// special treatment
}
关于如何特殊处理 本文后面会详细讲解
CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一 有着不可替代的重要地位
CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准 是在CGI程序和Web服务器之间传递信息的过程
其实 要真正理解CGI并不简单 首先我们从现象入手
浏览器除了从服务器下获得资源(网页 图片 文字等) 有时候还有能上传一些东西(提交表单 注册用户之类的) 看看我们目前的http只能进行获得资源 并不能够进行上传资源所以目前http并不具有交互式 为了让我们的网站能够实现交互式 我们需要使用CGI完成 时刻记着 我们目前是要写一个http 所以 CGI的所有交互细节 都需要我们来完成
理论上 可以使用任何语言来编写CGI程序
需要注意的是 http提供CGI机制 和CGI程序是两码事 就好比学校(http)提供教学(CGI机制)平台 学生(CGI程序)来学习
反应到具体的web服务器中 什么是CGI机制呢
我们首先创建一个handerdate.exe
作为一个可执行文件
调用目标程序 传递目标数据 拿到目标结果 这中间用到的就是CGI技术
那么我们什么时候需要用到CGI技术呢?
答案是只要用户上传上来数据此时我们就要用到CGI技术 此时我们只需要将cgi标志位设置为开启即可
最后我们通过判断cgi标志位是否开启来判断使用什么方法 代码表示如下
if (http_request.cgi)
{
ProcessCgi();
}
else
{
ProcessNonCgi(); // return html
}
关于大小写转化的问题
因为我们对于GET和POST方法并没有做出严格的大小写规定 而我们在项目中却使用了大写作为判定条件
这就有可能会导致一些错误的发生 所以说我们保证我们接收的method要转化为大写
C++提供了一个函数来实现这个功能
OutputIterator transform (InputIterator first1, InputIterator last1,
OutputIterator result, UnaryOperator op)
参数说明:
代码表示如下
auto& method = http_request.method;
std::transform(method.begin() , method.end() , method.begin() , ::toupper );
构建响应之前我们首先来回顾下响应的报文是什么样子的
所以说我们不单单要只返回一个静态网页(正文) 还需要加上前面的请求行 报头等信息
状态行由http版本 状态码 状态码描述符组成
其中我们默认http版本就是1.0版本
默认的状态码是200(OK)
状态码描述需要和状态码相匹配
此时我们只要设置一个函数 传入code输出一个状态码的string对象就可以 代码如下
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;
}
之后一步步写好状态行就好了
http_response._response_line = HTTP_VERSION;
http_response._response_line += " ";
http_response._response_line += std::to_string(http_response.response_code);
http_response._response_line += " ";
http_request._request_line += Code2Desc(http_response.response_code);
我们设置默认的响应行分隔符为 \r\n
#define LINE_END "\r\n"
之后响应报头的内容我们这里暂时跳过
在前面我们已经获取了请求读取的路径
一般来说现在我们只需要根据那个路径打开对应的资源 之后将资源写到报文的正文中即可
但是在实际填写正文的过程中我们会遇到这样子的问题
这里给大家介绍一个函数 sendfile
它的作用是不用经历用户层 直接在内核缓冲区拷贝数据 从而提高效率
它的函数原型如下
ssize_t sendfile(int out_fd, int in_fd , off_t* set , size_t count)
参数说明:
返回值说明:
如果成功拷贝则返回成功拷贝的字节数 失败返回-1
我们一步步将状态行 响应报头 空行 正文发送即可
void SendResponse()
{
write(_sock ,http_response._response_line.c_str() , http_response._response_line.size());
for(auto it : http_response._response_header)
{
write(_sock, it.c_str() , it.size());
}
write(_sock,http_response._blank.c_str() , http_response._blank.size());
sendfile(_sock , http_response.fd , nullptr , http_response.size);
close(http_response.fd);
}
最后我们将index.html中写上hello world
编译运行后使用浏览器尝试接收响应
运行结果如下
由于博主并没有系统的学习前端知识 所以说这里就不写网页的前端了
如果有同学感兴趣可以自己写一些前端的代码放到web根目录下
响应报头有很多字段可以填充 我们这里只填充两个比较重要的字段
一个是Content-length 即正文的大小
一个是Content-type 即正文的类型
正文的大小其实我们之前已经有过了 这里我们只需要插入到报头中即可 代码如下
std::string content_length_string = "Content-Length: ";
content_length_string += std::to_string(size);
而正文的类型则是我们比较难判断的一点
一般来说我们会根据文件名的后缀来判断这个文件是什么类型 当构建响应的时候我们也需要告知浏览器我们返回的是什么类型的资源
所以说我们的第一步操作就是后缀提取
found = http_request.path.rfind(".");
if (found == std::string::npos)
{
http_request.suffix = ".html";
}
else
{
http_request.suffix = http_request.path.substr(found);
}
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"},
{".jpg" , "text/html"}
};
auto iter = suffix2desc.find(suffix);
if (iter != suffix2desc.end())
{
return iter->second;
}
return "text/html";
}
当浏览器请求的资源是一个可执行文件的时候
此时我们的服务器就会触发CGI模式
其实早在进程控制章节我们就学过了方法 那就是进程替换
当然我们在讲解程序替换的时候也说过 我们不能使用主进程进行进程替换(使用主进程该进程就变成一次性的了 处理不了下一个任务) 而应该使用子进程
整体的代码如下
int ProcessCgi()
{
pid_t pid = fork();
if (pid == 0)
{
// child
}
else if (pid < 0)
{
// error
LOG(ERROR , "fork error");
return 404;
}
else
{
// father
waitpid(pid , nullptr , 0);
}
return OK;
}
我们创建子进程的目的当然是为了让他去执行目标程序
那么目标程序是什么呢 它实际上就是浏览器传输给我们的path
httpsever需要将数据传输给cgi程序 cgi程序处理完数据之后也需要将数据回传给httpsever
所以说我们这里就要用到进程间通信
因为httpsever进程和我们设计的cgi进程之间本质上是一个父子进程的关系 所以说我们选用进程间通信中的匿名管道
而由于此时我们需要数据进行双向传递 所以说我们可以设计一个双向的管道
为了不混淆这个双向管道的读取 我们约定 所有操作都站在父进程的视角上命名
代码表示如下
int input[2];
int output[2];
if (pipe(input) < 0)
{
return 404;
}
if (pipe(output) < 0)
{
return 404;
}
pid_t pid = fork();
if (pid == 0)
{
// child
close(input[0]);
close(output[1]);
}
else if (pid < 0)
{
// error
LOG(ERROR , "fork error");
return 404;
}
else
{
// father
close(input[1]);
close(output[0]);
waitpid(pid , nullptr , 0);
}
return OK;
}
我们选择使用execl函数来进行进程替换
函数原型如下
int execl(const char *path, const char *arg, ...);
我们先看这个函数的名字 相比我们的exec多了一个l
这个l其实就是列表的意思 意味着它的参数要使用列表的形式传入
它的第一个参数是 const char *path
它代表着要执行程序的路径
它的第二个参数是 const char *arg, ...
它代表着可变参数列表 是使用NULL结尾的
例如我们要执行ls程序的话 就可以写出下面的代码
execl("/usr/bin/ls" , "ls" , "-a" , "-i" , NULL);
当然如果我们直接使用路径作为可执行程序也是可以的 比如
execl("/usr/bin/ls" , "/usr/bin/ls" , "-a" , "-i" , NULL);
所以说我们程序替换的代码是
execl(bin.c_str() , bin.c_str() , nullptr);
我们在进行程序替换之后把原先子进程的程序和代码全部替换了
那么替换后的子进程如何得知原先的管道信息呢
代码和数据全部没有了 但是我们要知道的是也仅仅是代码和数据没有了
它并不替换内核进程相关的数据结构 实际上原先子进程的文件描述符表依旧存在
但是数据已经被我们全部删除了 我们要怎么找到呢这两个文件描述符呢?
此时我们可以做出以下的约定
而由于标准输入和标准输出的文件描述符是固定的
所以说我们直接进行重定向即可
dup2(input[1] , 1);
dup2(output[0], 0);
在交互数据之前我们首先要知道父进程的数据在哪里
对于GET方法来说父进程的数据一定是在uri当中
对于POST方法来说 父进程的数据一定是在正文当中
代码表示如下
if (method == "POST")
{
const char* start = body_text.c_str();
int total = 0;
int size = 0;
while(1)
{
size = write(output[1] , start+total , body_text.size()-total);
if (size > 0)
{
total+=size;
}
else
{
break;
}
}
}
上面的代码我们做了一个小处理 让write一直写 直到写入成功的数据为0为止 这主要是为了防止数据太多 一次write写不完的情况出现
首先我们要知道一点 进程替换是不会替换环境变量的 而子进程会继承父进程的环境变量 所以说我们可以直接使用父进程或者没有替换过的子进程的环境变量给替换后的子进程传递数据
代码标识如下
if (method == "GET")
{
query_string_env = "QUERY_STRING=";
query_string_env += query_string;
putenv(query_string.c_str());
}
在子进程接收数据之前我们还要让子进程确认一点
此时我们还是可以通过环境变量让子进程知道是用什么方法传递的参数
method_env = "METHOD=";
method_env += method;
putenv((char *)method_env.c_str());
我们首先写出一个CGI程序编译并且将这个程序放到wwwroot目录中
CGI程序代码如下
#include
#include
using namespace std;
int main()
{
cerr << "Debug Test :" << getenv("METHOD") << endl;
return 0;
}
这里需要注意的是我们使用的是cerr而不是cout 这是因为我们的cout使用的是标准输出 而标准输出已经被我们重定向了 如果使用cout将不会在显示器上输出任何结果
此时我们发现确实cgi程序确实能够得到浏览器传参的方法
接下来就是根据传参方法的不同使用不同的方式去获得数据了 具体为
代码表示如下
if (method == "GET")
{
query_string = getenv("QUERY_STRING");
cerr << "Debug QUERY_STRING: " << query_string << endl; }
else if (method == "POST")
{
int cl = atoi(getenv("CONTENT_LENGTH"));
char c = 0;
while(cl)
{
read(0 , &c , 1);
query_string.push_back(c);
cl--;
}
}
else
{
;
}
我们假设接收的数据是 x=100&y=200
那么首先我们先要得到各个参数的名称和值 很简单的一个字符串分隔即可
void CutString(string& in, const string& sep, string& out1 , string& out2)
{
auto pos = in.find(sep);
if (string::npos == pos)
{
return;
}
out1 = in.substr(0 , pos);
out2 = in.substr(pos+sep.size());
}
值得注意的是 第二个参数最好加上const修饰 原因有二
does not name a type 错误
博主在使用auto推导pos类型的时候遇到了这个错误
实际上在这里这个错误的产生的原因是在makefile文件中没有使用c++11来编译该文件的 加上-std=c++11之后错误即可解决
之后我们调用函数处理这批数据即可
string str1;
string str2;
CutString(query_string , "&" , str1 , str2);
string name1;
string value1;
CutString(str1 , "=" , name1 , value1);
string name2;
string value2;
CutString(str2 , "=" , name2 , value2);
在cgi程序中 我们让程序向管道中写入了自己处理完的数据
到了父进程当中 我们只需要让父进程从管道中读取数据即可 代码如下
char ch = 0;
while(read(input[0] , &ch , 1))
{
response_body.push_back(ch);
}
我们的CGI程序总结可以浓缩为下面的一张图
我们如果将中间的步骤全部省略 就能得到这样一个图
这样设计有什么好处呢?
实际上我们使用cgi模式将通信和服务高度解耦了
这使得我们的cgi程序不必关注通信细节 只需要专心设计好服务即可
HTTP的状态码如下:
编号 | 类别 | 意义 |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
其中我们要记住的有下面这几个
101 信息请求中
101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了
200 OK
这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行
301 永久重定向
比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上
此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站
302 307 临时重定向
从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站
403 权限不足
这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码
404 NOT FOUND
常见于资源消失不见(被删除或过期) 又或者说资源根本不存在
比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码
504 Bad Gateway
常见于服务器出现问题 和客户端无关
Redirection(重定向状态码)
除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码
重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向
临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址
如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站
而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站
本项目中只使用了状态码404来减少工作量
在同学们围绕这个项目做拓展的时候可以参考上面的状态码写出更多 代码大同小异 难度也不大 主要是html页面的编写
一般来说我们的http项目会出现两种类型的错误
此处我们针对读取错误做出一些处理
首先我们在EndPoint类中设置一个成员变量bool stop
并且在构造函数中将它默认设定为false
之后我们将EndPoint里面的一些读取函数中加上读取错误判定
一旦我们判定此处读取错误则设置stop = true
此处列举几处需要添加读取判断的地方 具体内容可以参考gitee上的源码(在文章的最后)
bool RecvRequestLine()
{
if(Util::ReadLine(_sock ,http_request._request_line) >0 )
{
std::cout << http_request._request_line ;
}
else
{
stop = true;
}
return stop;
}
bool RecvRequestHeader()
{
std::string line;
while(true)
{
line.clear();
if (Util:: ReadLine(_sock , line) <= 0)
{
stop = true;
}
if (line == "\n")
{
break;
}
line.resize(line.size()-1); // remove \n
http_request._request_header.push_back(line);
}
if (line == "\n")
{
http_request._blank = line;
}
return stop;
}
如果说stop为true 则我们在最后就不构建和发送数据了 代码表示如下
if (!ep->Stop())
{
ep->BuildResponse();
ep->SendResponse();
}
在进程间通信这一章中我们讲过两个进程通信的四种特殊情况
这其中有一种情况就是 读取端关闭 写入端就会强制关闭
实际上我们在深入理解之后也知道了 这是因为操作系统发送了13号信号的缘故
而在我们这次的项目当中 服务器是要尽量保持24h开机的 不可能因为读取端退出就关闭
所以说我们要忽略13号信号的作用 代码表示如下
signal(SIGPIPE , SIG_IGN);
目前我们所使用的执行任务的方式是多线程 这种方式有以下的缺点
而以上的问题我们都可以通过线程池来解决一部分
所以说为了增强代码的健壮性我们将原本的多线程模式转化为线程池模式
设计思路如下
有细心的同学可能发现了 这实际上就是一个生产者消费者模型
那么我们首先来设计一个任务类
任务类只需要有两个成员变量
套接字是为了让线程知道要处理什么
处理方法是为了让线程只要要用什么方法处理
代码表示如下
#pragma once
#include
class Task
{
private:
int sock;
CallBack handler;
public:
Task()
{}
Task(int _sock)
:sock(_sock)
{}
void ProcessOn()
{
handler(sock);
}
~Task()
{}
};
设计回调函数
调用函数处理这个工作之前已经有一个Entrance 类做到过了 我们要做的只是给他改个名字
class CallBack
{
public:
CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock);
}
之后再给这个类加上一个仿函数方便我们后续调用
线程池的设计思路如下
代码表示如下
class ThreadPool
{
private:
std::queue<Task> task_queue;
int num;
bool stop;
pthread_mutex_t lock;
pthread_cond_t cond;
之后就是一些常见的函数编写
比如说 加锁 解锁 睡眠 唤醒 添加任务 删除任务等等 这些代码在生产者消费者模型那一章节已经写过 这里就不再赘述 需要看全部代码的同学可以在文末连接处查看
此处会涉及到一些前端知识 有兴趣的同学可以去深入了解下
一般来说我们可以通过表单从前端页面向后端提交数据
表单的格式如下
<form>
.
form elements
.
</form>
我们可以写一个表单网页来测试我们的程序
它使用GET方法传参 分别传递两个数据x和y
<!DOCTYPE html>
<html>
<body>
<form action="/testcgi" method="GET">
First name:<br>
<input type="text" name="date_x" value="0">
<br>
Last name:<br>
<input type="text" name="date_y" value="1">
<br><br>
<input type = "submit" value="submit">
</form>
</body>
</html>
我们前面学过了 GET方法是通过url传参的
那么当我们点击submit提交的时候在url中也应该出现参数
我们发现结果确实符合预期
除了c++之外 我们的cgi程序还可以使用其他多种语言编写
比如说c语言 python java php
由于博主目前为止只学过c/c++ 没办法给大家演示 同学们如果有什么有意思的程序也可以在cgi上做一些扩展
gitee