计算机网络(六) —— http协议详解
目录
一,预备知识
1.1 关于域名
1.2 关于URL
1.3 urlencode和urldecode
二,关于http
2.1 什么是http
2.2 http协议格式
2.2.1 网络协议栈
2.2.2 http请求协议格式*
2.2.3 http响应协议格式*
三,http细节字段
3.1 http方法
3.2 状态码
3.3 http常见的Header
3.4 http版本
3.5 Cookie和Session
四,http服务器代码优化
4.1 支持显示图片等其它数据
4.2 逻辑梳理
一,预备知识
1.1 关于域名
- 我们一般访问一个网站的时候,不是直接拿着“点分十进制”的IP和端口去访问的,比如我要访问“百度”,是通过https://www.baidu.com去访问的,而想要访问一个服务,只需要知道IP地址和端口号就可以了,但是在日常中,我们一般不直接使用IP,而是使用域名这样的东西
- 其实域名本身和技术相关的东西关联程度并不大,只是相比较IP地址的数字,比较容易记,而且我们也不能让用户直接拿IP去搞,这样用户体验会非常差
- 域名会被域名解析服务解析成IP地址的,这个我们不用关心,浏览器会帮我们搞好
总结:域名只是为了方便用户访问,其最终访问服务器还是要用IP地址的
1.2 关于URL
URL(Uniform Resource Lacator),称为统一资源定位符,本质是一个字符串,也就是我们常说的网址,如名字一样,能够在互联网当中精确定位唯一的某种资源(这里的资源指的是文本数据,音频数据,视频数据等)
我们来介绍一下上面的东西:
- 协议方案名:表示使用的协议,通常为http和https,后面会介绍
- 登录信息:一般由登录用户名和密码两部分构成,可以通过URL传给服务器,但是大家都懂,这样会直接暴露,不安全,所以一般通过其它方式提交登录信息
- 服务器地址:这个就是我们前面讲的域名,是将指定服务器的IP做映射后得到的一个字符串,能方便用户访问
- 服务器端口号:我们在浏览器输入一个IP之后,默认使用的就是http或https,http默认绑定的端口是80,https默认绑定的是443是固定的;但是我们用户一般不自己传端口,因为大部分知名网站,它们的端口一般是众所周知的,就像110,119等紧急电话一样,必须是严格的一对一匹配,所以我们访问一些知名网站时,不带端口号,但是浏览器依旧会知道端口号,浏览器在请求里会默认添加端口号
- 带层次的文件路径:访问服务器目的是获取服务器上的某种资源,所以文件路径就是干这个的,找打服务器上对应路径的资源(而且很多的路径分隔符是 / ,这说明还是Linux做服务器的多,Windows的少)
- 查询字符串:表示请求时额外提供的参数,以键值对的形式,用户可以通过url向服务器传输数据
- 片段标识符:对资源的补充,了解一下即可
1.3 urlencode和urldecode
以上面的图为例,URL里面也有很多特殊字符的;但是如果我们用户自己输入的关键字当中出现类似 /?: 这样的字符,为了避免URL字符串解析冲突,浏览器会对我们搜索关键字的特殊字符进行转义,如下图:
转义规则:
- 将需要转码的字符转为十六进制,然后从左到右,取4位(不足4位直接处理),没两位做一位,前面加上 % ,编码成 %XY 的格式
- 比如上面的图, + 转为十六进制后为 0x2B ,所以一个 + 号被转成 %2B
总结:上网行为就两种,把我的资源传上去,把别人的资源拿过来;我们自己上传的符号可能回合URL某些字符冲突,所以浏览器会自己做编码
二,关于http
2.1 什么是http
HTTP(Hyper Text Transfer Protocol),又叫做超文本传输协议,底层是Tcp协议,是一个简单的“请求-响应”协议
我们前面已经实现了一个网络版计算器的自定义协议:计算机网络(五) —— 自定义协议简单网络程序-CSDN博客
所以我们可以自己定制协议,但是互联网发展了这么多年,许多优秀的工程师和程序员早已经搞出了许多非常优秀的应用层协议,并经过这么多年的维护,许多协议已经非常成熟,我们直接用就行,而其中应用最广泛,最典型的就是HTTP协议
2.2 http协议格式
2.2.1 网络协议栈
网络协议栈四层的常用协议如上,其中下三层是由操作系统或者驱动帮我们完成的,主要负责通信细节。如果应用层不考虑下三层,那么在应用层看来,它就可以任务是自己在和对方的应用层通信
HTTP是基于“请求-响应”的应用层服务,客户端可以向服务器发起请求,服务器收到请求之后,会根据http协议解析请求,然后就会知道你想要访问什么资源,然后服务器再构建响应,完成这一次HTTP请求
所以,学习HTTP的请求格式和响应格式,是我们学习HTTP的重点
2.2.2 http请求协议格式*
http请求协议简略图如下:
请求行:
- 请求方法Method:有很多,但我们主要讲GET和POST方法,为获取和上传,因为这两个方法占所有方法使用率的95%以上
- url:就是我们前面讲的url,我们的url是通过http协议报头传给服务器的
- http版本:http Version,为协议的版本,最常见的是1.1,长连接,1.0就是短连接
请求报头:
- 都是以key : value 的形式按行陈列的,都是http为了应对各种情况,需要和双方协商的字段
空行:
- 该行的主要作用,就是服务器在读取报文的时候,入股遇到了空行,说明报头读取完毕,所以空行能够将报头和有效载荷分离
请求正文:
- 请求正文一般是用户相关信息或数据,如果用户没有要上传给服务器的信息,请求正文就是空字符串
- 如果有信息要上传,在请求报头中会有一个Content-Length,表示正文的长度,这样就能读取到完整的正文了
说多了也没用,下面我们直接写代码。
既然你说http请求报文是这样那样,那它到底是个啥样?我们下面来写一个简单的服务器代码,使用浏览器访问,当浏览器访问我们的服务器时,就会发送http请求报文,而我们的服务器就能接收到报文,从而打印出来,如下代码:
需要用到的文件为:
其中Socket.hpp,Log.hpp,makefile三个文件前面已经实现过,这里不再赘述:计算机网络(五) —— 自定义协议简单网络程序-CSDN博客
下面是HttpServer.hpp的代码:
#pragma once
#include "Socket.hpp"
#include "Log.hpp"
#include <iostream>
#include <string>
#include <pthread.h>
static const int defaultport = 8080;
struct ThreadData
{
int sockfd;
};
class HttpServer
{
public:
HttpServer(uint16_t port = defaultport)
: _port(port)
{
}
~HttpServer()
{
}
bool Start()
{
_listensock.Socket(); // 创建套接字
_listensock.Bind(_port); // 绑定套接字
_listensock.Listen(); // 监听套接字
while (true)
{
std::string clientip;
uint16_t clientport;
int sockfd = _listensock.Accept(&clientip, &clientport); // 获取连接
pthread_t tid;
ThreadData *td = new ThreadData;
td->sockfd = sockfd;
pthread_create(&tid, nullptr, ThreadRun, td);
}
}
static void *ThreadRun(void *args)
{
pthread_detach(pthread_self()); // 线程分离
char buffer[10240];
ThreadData *td = static_cast<ThreadData *>(args); // 表示把args的类型强转为THreadData
// man 2 recv 也是可以进行Tcp套接字读取的,它的返回值和使用方法和read几乎一模一样,recv比read多了个参数,flags,标识读取方式,当flags为0时,它的作用就和read一样了
ssize_t n = recv(td->sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer; // 读到什么内容就打印什么内容
}
close(td->sockfd);
delete td;
return nullptr;
}
private:
Sock _listensock;
uint16_t _port;
};
下面是HttpServer.cc的代码:
#include "HttpServer.hpp"
int main(int argc, char *argv[])
{
if (argc != 2)
{
exit(1);
}
uint16_t port = std::stoi(argv[1]);
// std::unique<HttpServer> svr(new HttpServer);
HttpServer *svr = new HttpServer(port);
svr->Start();
return 0;
}
解释:
- 浏览器发起HTTP请求,但是我们目前的服务器未返回任何响应,所以浏览器会认为我们没收到请求,所以浏览器会多次重新发请求,所以会打印多个http报文
- 由于浏览器发起请求时默认是http协议,所以我们在浏览器的输入框里,不需要指名是http协议
- 对于GET方法后面的“ / ”,很快能发现这是一个根目录,但是这个根目录不是Linux系统的根目录,是web根目录,我们可以自己设置这个根目录,当url有路径时,就从web根目录开始找资源的
2.2.3 http响应协议格式*
响应报文其实和请求报文很相似,有区别的就是状态码,这个我们后面具体讲
下面我们继续写代码,浏览器给我发了请求,所以我就要构建响应报文并返回回去,如下代码:
由于我们需要返回相应,所以我们需要知道请求报文里的内容,所以我们先定义一个请求类,类中包含分离有效载荷,和解析有效载荷两种方法:
class HttpRequest
{
public:
void Deserialize(std::string req) // 反序列化,其实就是做字符串切割,把一个http请求字符串变成多个字符串并存起来
{
while (true)
{
size_t pos = req.find(sep); //找换行符
if (pos == std::string::npos)
break; // 找到结尾了,代表全部截完了
// 开始截取字符串
std::string temp = req.substr(0, pos);
if (temp.empty())
break; // 碰到了空行,代表当前报文的报头反序列化完成
req_header.push_back(temp); // 把每一个截取到的数据搞到vector里面去
req.erase(0, pos + sep.size()); // 把原来http第一行的内容去掉,这样下次再找的时候就找第二行,这样找一行移一行,就能在遇到空行前把数据全搞到数组里面去
}
text = req; //报头全截取完删完后,剩下的就是请求正文
}
void Parse() // 解析
{
std::stringstream ss(req_header[0]); // req_header数组保存着第一行字符串,使用stringstream需要以空格为分隔符,以流的方式拆成4个字符串
ss >> method >> url >> http_version; // 这样就分开了,而且顺序不能改,这是http的规定
file_path = wwwroot;
// 我们需要保证开始访问的路径是从web根目录开始的
if (url == "/" || url == "/index.html") // 如果请求的是“ / ”也就是首页,就加上首页的web地址
{
file_path += "/";
file_path += homepage; // 最后变成 ./wwwroot/index.html
}
else //如果url是其它地址就加上其它地址
{
file_path += url; // /a/b/c/d.html -> ./wwwroot/a/b/c/d.html
}
//文件都是二进制数据,但是不同的文件有不同的二进制序列,所以需要告诉浏览器,这个文件是什么格式的,后面再讲,这里先放着
auto pos = file_path.rfind("."); // 从后往前找点,截取文件后缀
if (pos == std::string::npos)
{
suffix = ".html";
}
else
{
suffix = file_path.substr(pos); // 从点的位置往后截取直到结尾,拿到文件后缀
}
}
// 测试打印反序列化结果
void DebugPrint()
{
for (auto &line : req_header)
{
std::cout << line << std::endl;
std::cout << "---------------------" << std::endl;
}
std::cout << "method: " << method << std::endl;
std::cout << "url: " << url << std::endl;
std::cout << "http_version: " << http_version << std::endl;
std::cout << "file_path: " << file_path << std::endl;
std::cout << text << std::endl;
}
public:
std::vector<std::string> req_header; // 请求行
std::string text; // 请求正文
// 存储解析之后的结果
std::string method; //请求得方法
std::string url; //请求的url
std::string http_version; // 请求的http版本
std::string file_path;
std::string suffix; // 文件后缀
};
当请求报文解析完成后,接下来要做的就是构建响应报文:
- 构建响应报文,我们需要自己构建响应报头,自己加上状态行和响应报头等信息,并且以“ \r\n ”隔开
- 然后就是把资源放到报头后面,这里我们返回一个浏览器页面,也就是html文件,浏览器会解析这个html文件,然后显示页面,这属于前端的知识
- 还有就是,如果请求中的url里的路径,在我们的服务器上并不存在,所以就应该返回一个404页面,这个大家应该都知道
所以我们就在当前目录下建立一个“wwwroot”目录作为我们的web根目录,再建立 index.html 首页文件和 err.html 404页面,然后再建立几个其它的测试文件,这个直接从我的gitee下载即可:
hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<form action="./a/b/hello.html" method="get">
name:<input type="text" name="name"><br>
password:<input type="password" name="passwd"><br>
<input type="submit" value="提交">
</form>
</body>
</html>
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
a {
color: blue;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
width: 536px
}
.title .col-1 {
font-size: 20px;
font-weight: bolder;
}
.col-1 {
width: 80%;
text-align: left;
/*居左*/
}
.col-2 {
width: 20%;
text-align: center;
}
.icon {
background-image: url(./male.png);
width: 24px;
height: 24px;
background-size: 100% 100%;
display: inline-block;
/*加上后图片才能显示出来*/
vertical-align: bottom;
/*使垂直对齐*/
}
.content {
font-size: 18px;
line-height: 30px;
}
.content .col-1,
.content .col-2 {
border-bottom: 2px solid #f3f3f3;
}
.num {
font-size: 20px;
color: #fffff3;
}
.first {
background-color: #f54545;
padding-right: 8px;
}
.second {
background-color: #ff8547;
padding-right: 8px;
}
.third {
background-color: #ffac38;
padding-right: 8px;
}
.other {
background-color: #81b9f5;
padding-right: 8px;
}
</style>
</head>
<body>
<table cellspacint="0px">
<th class="title col-1">热搜</th>
<th class="title col-2"><a href="./a/b/hello.html">登录<span class="icon"></span></a></th>
<tr class="content">
<td class="col-1"><span class="num first">1</span><a
href="https://github.com/"
target="blank">GitHub</a>
</td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num second">2</span><a href="https://www.csdn.net/"
target="blank">CSDN</a></td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num third">3</span><a href="https://gitee.com/"
target="blank">Gitee</a></td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num other">4</span><a href="https://leetcode.cn/"
target="blank">LeetCode</a></td>
<td class="col-2">666万</td>
</tr>
<tr>
<td>
<a href="./image.html" target="blank">你好</a>
</td>
</tr>
</table>
</body>
</html>
image.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<img src="/image/1.png" alt="你好" weigh="800px" width="800px">
<img src="/image/2.png" alt="你好" weigh="800px" width="800px">
<img src="/image/3.png" alt="你好" weigh="800px" width="800px">
</body>
</html>
err.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>404 Not Found</h1>
<h21>您好,您访问的页面不存在</h21>
</body>
</html>
然后就是针对HttpServer.hpp的修改了:
#pragma once
#include "Socket.hpp"
#include "Log.hpp"
#include <iostream>
#include <string>
#include <pthread.h>
#include <vector>
#include<sstream>
#include<fstream>
class HttpServer;
const std::string wwwroot = "./wwwroot"; // web根目录
// 配置文件里面就是一堆的路径,服务开始时以配置文件来初始化根目录
const std::string sep = "\r\n";
const std::string homepage = "index.html";
static const int defaultport = 8080;
class ThreadData
{
public:
ThreadData(int fd, HttpServer *s)
: sockfd(fd), svr(s)
{
}
int sockfd;
HttpServer *svr;
};
class HttpRequest
{
public:
void Deserialize(std::string req) // 反序列化,其实就是做字符串切割,把一个http请求字符串变成多个字符串并存起来
{
while (true)
{
size_t pos = req.find(sep); //找换行符
if (pos == std::string::npos)
break; // 找到结尾了,代表全部截完了
// 开始截取字符串
std::string temp = req.substr(0, pos);
if (temp.empty())
break; // 碰到了空行,代表当前报文的报头反序列化完成
req_header.push_back(temp); // 把每一个截取到的数据搞到vector里面去
req.erase(0, pos + sep.size()); // 把原来http第一行的内容去掉,这样下次再找的时候就找第二行,这样找一行移一行,就能在遇到空行前把数据全搞到数组里面去
}
text = req;
}
void Parse() // 解析
{
std::stringstream ss(req_header[0]); // req_header数组保存着第一行字符串,使用stringstream需要以空格为分隔符,以流的方式拆成4个字符串
ss >> method >> url >> http_version; // 这样就分开了,而且顺序不能改,这是http的规定
file_path = wwwroot;
// 我们需要保证开始访问的路径是从web根目录开始的
if (url == "/" || url == "/index.html") // 如果请求的是首页
{
file_path += "/";
file_path += homepage; // 最后变成 ./wwwroot/index.html
}
else
{
file_path += url; // /a/b/c/d.html -> ./wwwroot/a/b/c/d.html
}
auto pos = file_path.rfind("."); // 从后往前找点,截取文件后缀
if (pos == std::string::npos)
{
suffix = ".html";
}
else
{
suffix = file_path.substr(pos); // 从点的位置往后截取直到结尾,拿到文件后缀
}
}
// 测试打印反序列化结果
void DebugPrint()
{
for (auto &line : req_header)
{
std::cout << line << std::endl;
std::cout << "---------------------" << std::endl;
}
std::cout << "method: " << method << std::endl;
std::cout << "url: " << url << std::endl;
std::cout << "http_version: " << http_version << std::endl;
std::cout << "file_path: " << file_path << std::endl;
std::cout << text << std::endl;
}
public:
std::vector<std::string> req_header; // 请求行
std::string text; // 请求正文
// 存储解析之后的结果
std::string method; //请求得方法
std::string url; //请求的url
std::string http_version; // 请求的http版本
std::string file_path;
std::string suffix; // 文件后缀
};
class HttpServer
{
public:
HttpServer(uint16_t port = defaultport)
: _port(port)
{
}
~HttpServer()
{
}
bool Start()
{
_listensock.Socket(); // 创建套接字
_listensock.Bind(_port); // 绑定套接字
_listensock.Listen(); // 监听套接字
while (true)
{
std::string clientip;
uint16_t clientport;
int sockfd = _listensock.Accept(&clientip, &clientport); // 获取连接
if (sockfd < 0)
continue;
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this); //把this指针传过去,使静态成员方法能够访问类内成员
pthread_create(&tid, nullptr, ThreadRun, td);
}
}
static std::string ReadHtmlContent(const std::string &htmlpath) //获取url中的地址中的文件资源
{
std::ifstream in(htmlpath, std::ios::binary); // 以二进制方式来读
if (!in.is_open())
return "";
// 以字符串方式读,传文本还好,传图片视频等二进制数据时,就不行了,所以要在上面ifstream的第二个参数带上std::ios::binary,表示以二进制方式来读
// std::string line;
// std::string content;
// while (std::getline(in, line)) // 第一个是流,第二个是string,从流读到line里面
// {
// content += line;
// }
// C++读取二进制大小
//读取前需要知道文件的大小,先把文件读写位置放到结尾,就可以得到大小,然后再把文件读写位置放到开始
in.seekg(0, std::ios_base::end); // 将文件读写开始位置定位到结尾
auto len = in.tellg(); // 返回文件大小
in.seekg(0, std::ios_base::beg); // 再把文件开始位置返回开头
std::string content;
content.resize(len); //把缓冲区大小调整为文件的大小
in.read((char *)content.c_str(), content.size()); // 二进制读
in.close();
return content;
}
void HandlerHttp(int sockfd) //构建响应,发回去浏览器
{
char buffer[10240];
// man 2 recv 也是可以进行Tcp套接字读取的,它的返回值和使用方法和read几乎一模一样,recv比read多了个参数,flags,标识读取方式,当flags为0时,它的作用就和read一样了
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
// 假设我们读取到的就是一个完整的,独立的http请求
std::cout << buffer << std::endl;
// 读到什么内容就打印什么内容
HttpRequest req;
req.Deserialize(buffer); //解析有效载荷
req.Parse();
//req.DebugPrint(); //可以测试响应报文构建成功不成功
// 请求解析完成之后,返回响应
std::string text;
bool ok = true;
text = ReadHtmlContent(req.file_path); // 将指定路径下的文件的内容返回给text,就是我们前面构建的html文件的内容
if (text.empty()) // 如果读取的内容不存在,就返回一个404页面
{
ok = false;
std::string err_html = wwwroot;
err_html += "/";
err_html += "err.html";
text = ReadHtmlContent(err_html); //讲响应正文变成404页面的内容
}
std::string response_line;
if (ok)
{
response_line = "HTTP/1.0 200 OK\r\n"; //①添加响应行,包含协议名,协议版本,和状态码
}
else
{
response_line = "HTTP/1.0 404 Not Found\r\n";
}
std::string response_header = "Content-Length: "; // ②添加响应报头
response_header += std::to_string(text.size()); // 报头添加正文的长度
response_header += sep;
std::string response = response_line;
response += response_header; //加报头
response += sep;
response += text; // 加有效载荷
//最后到这里后就是一个完整的报文了
// 把消息发回去 man 2 send
send(sockfd, response.c_str(), response.size(), 0);
}
close(sockfd);
}
static void *ThreadRun(void *args)
{
pthread_detach(pthread_self()); // 线程分离
char buffer[10240];
ThreadData *td = static_cast<ThreadData *>(args); // 表示把args的类型强转为THreadData
td->svr->HandlerHttp(td->sockfd);
delete td;
return nullptr;
}
private:
Sock _listensock;
uint16_t _port;
};
就能显示我们刚刚写的html页面了,“你好”选项是显示图片的,但是目前显示不出来,我们后面解释
三,http细节字段
3.1 http方法
有很多:
方法 | 说明 | 支持的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 |
95%都是GET和POST这两个方法,然后这95%当中80是GET,20是POST
问题:我们日常访问某些网站的时候,是如何把我们的数据提交给服务器的呢?
解答:
就拿我们上面的hello.html为例,是一个输入账号和密码的页面,而在代码中,我们使用的方法是get:
而我们输入账号密码后,再来看服务器收到的报文:
可以发现,我们的数据是直接嵌套进URL再传给服务器的,是通过“表单”提交的
但是我们一般不用URL传参数,原因有:
- 当参数过多时,URL就会很长,不便于服务器解析
- URL会直接暴露出去,有安全问题
所以我们可以用post方法,就是讲我们的数据放在请求正文里传给服务器:
很多人说GET不安全,其实POST也不安全,这个和加不加密有关系,所以POST比GET安全一些,但总体都不安全,具体要看主页面对数据做的加密
3.2 状态码
码 | 类别 | 原因短语 |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
最常见的就是404 not found,403就是Forbidden,无访问权限,504就是Bad Gateway,服务器错误,比如服务器收到一个请求,然后要创建一个线程在提供服务,但是这个线程创建失败了,叫做服务器内部出错
其它的都好理解,但是3开头的重定向状态码,我们需要解释一下:
- 重定向就是通过各种方法讲网络请求重新定位转到其它位置,此时这个服务器相当于提供一个引路的服务
- 分为“临时重定向(302,307)”和“永久重定向(301)”,本质是影响客户端的标签,决定客户端是否需要更新目标地址
- 如果某个网站是永久重定向,那么第一次访问该网站时浏览器会帮你跳转,后续访问就是直接访问永久重定向后的网站了
- 如果是临时重定向,则每次都要浏览器来做一次跳转工作
下面我们来演示一下重定向:
报头字段中有一条是Location字段,表明所要重定向到的目标网站:
只需要在报头字段添加302状态码和Location就可以了
3.3 http常见的Header
- Content-Type:数据类型(text/html等)
- Content-Length:正文的长度
- Host:客户端告知服务器,所请求得资源是在哪个主机得哪个端口上
- User-Agent:用户得操作系统和浏览器版本等信息
- Referer:当前页面是由哪个页面跳转过来的
- Location:搭配3XX状态码用,重定向功能
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能
问题:客户端访问服务器,为什么还要告诉服务器它要访问的服务对应的IP和端呢?
解答:因为有些服务器提供的是代理服务,也就是代替客户端向其它服务器发起请求,所以需要Host信息
我们用电脑或者手机去访问百度等知名网站,可以发现电脑和手机浏览器显示的页面都不一样,因为User-Agent表明着客户端的一些设备信息,所以服务器就能根据这个字段返回适配不同设备的主页面
3.4 http版本
我们目前见到的版本就是1.0和1.1,1.0提供的是短链接服务,1.1提供的是长链接服务
- 短链接:一次请求响应一个资源,关闭连接,比如一个网页要显示100张图片,所以我就要发送101个http请求,这时候服务器就要在一个时刻建立101个线程,每个线程都单独建立基于Tcp套接字的连接,传输好图片资源后再关闭连接,这样的操作要101次,效率无疑是非常低下的。
- 长链接:所以今天就是把Tcp连接好,然后我把我要发送的多个http请求串联起来一起发给服务器,然后服务器就一个个处理,一个一个发送,但是我么只建立一次Tcp连接,发送和返回多个http的request和response,等http全部处理完后再关闭连接
- HTTP请求报头中的Connect字段,如果值是Keep-Alive,那么就是支持长链接
问题:为什么要交互http版本
解答:主要还是为了兼容性问题,因为服务器和客户端使用的可能是不同的http版本,为了让不同版本的客户端都能受到服务,就要求双方进行版本通信
3.5 Cookie和Session
HTTP是一种无状态协议,就是HTTP的每次“请求-响应”之间是没有任何关系的。但是我们在一些网站登录之后,在往后一段时间内,都不需要再次登录,每次打开就是默认登录的。
这就是通过Cookie技术实现的,Cookie可以在浏览器上查看:
下面解释一下什么是Cookie:
- 浏览器关掉再打开,B站不需要再次登录,电脑关机再开机后也一样,所以浏览器把我的登录状态记录了
- 我们登录时通过post,在http正文部分把我们的账号密码交给了B站后端进行认证,再发送http给浏览器表示认证通过,再做重定向,这时候会在报头添加Set-Cookie,浏览器就会把Cookie保存起来放到一个小文件里,这个文件我们就叫做Cookie文件
浏览器保存Cookie文件有两种方法,一种是磁盘级,一种是内存级,不同的浏览器保存方式可能不一样,俗称的“盗号”就是盗取的Cookie(1,个人的Cookie被盗取由别人来冒充我的身份 2,还有个人私有信息被泄露问题)
所以对于盗号问题,服务器也有解决方案:
- 浏览器输入账号密码,传输给服务器进行认证,服务器端为我们用户创建一个session文件,里面记录用户的登录相关的内容,里面会随机生成一个全服务器唯一的一个个序号叫做session_id
- 然后http响应时调用Set-Cookie,然后把当前用户的Session_id写回到浏览器的Cookie文件中,之后浏览器再次发送请求会把账号密码和session_id一起传上去,之后通过session_id是否合法来同意用户登录
- 然后服务端服务器就把上亿个session文件先描述,再组织,大部分企业都是把整个session信息同一托管给redis集群来管的
- 但是黑客还是可以盗走你的cookie冒充你的身份,但是你的个人信息不会再泄漏了,因为你的个人信息都是交给服务器维护了,以前都是浏览器维护的用户信息
- session_id是由服务器端生成分配的,这也代表着服务器端可以随时回收这个id,我前一分钟还在湖南登录,一分钟后我又在缅北登录了,而用户信息是由服务器维护的,所以服务器就能识别到登录地区的异常,直接在服务器端干掉你这个session_id给你重新分配,当然缅北那个也就要重新登录,而重新登录就要手机号等等,安全性就有了(服务器对用户的行为做检测)
所以我们也可以在我们服务器的响应报头加上Cookie字段:
四,http服务器代码优化
4.1 支持显示图片等其它数据
上面我们写的服务器中,网页可以正常显示,也可以正常跳转, 但是图片却无法显示,这是因为网页的html文件,其内容全部都是字符串内容,可以直接传输,浏览器也可以直接解析,但是图片等数据不一样,它是二进制数据,需要以特定方式去排列,所以我们服务器也需要告诉浏览器数据的类型
所以就要用到报头的Content-Type字段,表示文件的类型,所以我们服务器需要做的步骤有两个:
- 取得请求文件的后缀
- 将后缀与类型做映射,并添加到响应报头中返回给浏览器
对于不同的文件后缀,Content-Type也有对应的表示:常见 content-type对应表_xlsx contenttype-CSDN博客
我们先可以在构造函数中,添加我们的映射:
然后就是获取文件后缀并映射返回的函数:
std::string SuffixToDesc(const std::string &suffix) // 把读取到的文件后缀通过映射转化成Content-type所需要的参数
{
auto iter = content_type.find(suffix);
if (iter == content_type.end())
{
return content_type[".html"]; // 没找到默认返回 text/html
}
else
{
return content_type[suffix]; // 返回映射后的参数
}
}
最后,就是将类型添加进报头中:
完整代码在后面
4.2 逻辑梳理
完整代码:
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <fstream>
#include <vector>
#include <sstream>
#include <sys/types.h>
#include <sys/socket.h>
#include <unordered_map>
#include "Socket.hpp"
#include "Log.hpp"
const std::string wwwroot = "./wwwroot"; // web根目录
// 配置文件里面就是一堆的路径,服务开始时以配置文件来初始化根目录
const std::string sep = "\r\n";
const std::string homepage = "index.html";
static const int defaultport = 8080;
class HttpServer;
class ThreadData
{
public:
ThreadData(int fd, HttpServer *s)
: sockfd(fd), svr(s)
{
}
int sockfd;
HttpServer *svr;
};
class HttpRequest
{
public:
void Deserialize(std::string req) // 反序列化,其实就是做字符串切割,把一个http请求字符串变成多个字符串并存起来
{
while (true)
{
size_t pos = req.find(sep); //找换行符
if (pos == std::string::npos)
break; // 找到结尾了,代表全部截完了
// 开始截取字符串
std::string temp = req.substr(0, pos);
if (temp.empty())
break; // 碰到了空行,代表当前报文的报头反序列化完成
req_header.push_back(temp); // 把每一个截取到的数据搞到vector里面去
req.erase(0, pos + sep.size()); // 把原来http第一行的内容去掉,这样下次再找的时候就找第二行,这样找一行移一行,就能在遇到空行前把数据全搞到数组里面去
}
text = req;
}
void Parse() // 解析
{
std::stringstream ss(req_header[0]); // req_header数组保存着第一行字符串,使用stringstream需要以空格为分隔符,以流的方式拆成4个字符串
ss >> method >> url >> http_version; // 这样就分开了,而且顺序不能改,这是http的规定
file_path = wwwroot;
// 我们需要保证开始访问的路径是从web根目录开始的
if (url == "/" || url == "/index.html") // 如果请求的是首页
{
file_path += "/";
file_path += homepage; // 最后变成 ./wwwroot/index.html
}
else
{
file_path += url; // /a/b/c/d.html -> ./wwwroot/a/b/c/d.html
}
auto pos = file_path.rfind("."); // 从后往前找点,截取文件后缀
if (pos == std::string::npos)
{
suffix = ".html";
}
else
{
suffix = file_path.substr(pos); // 从点的位置往后截取直到结尾,拿到文件后缀
}
}
// 测试打印反序列化结果
void DebugPrint()
{
for (auto &line : req_header)
{
std::cout << line << std::endl;
std::cout << "---------------------" << std::endl;
}
std::cout << "method: " << method << std::endl;
std::cout << "url: " << url << std::endl;
std::cout << "http_version: " << http_version << std::endl;
std::cout << "file_path: " << file_path << std::endl;
std::cout << text << std::endl;
}
public:
std::vector<std::string> req_header; // 请求行
std::string text; // 请求正文
// 存储解析之后的结果
std::string method; //请求得方法
std::string url; //请求的url
std::string http_version; // 请求的http版本
std::string file_path;
std::string suffix; // 文件后缀
};
class HttpServer
{
public:
HttpServer(uint16_t port = defaultport)
: _port(port)
{
content_type.insert({".html", "text/html"}); // 初始化,有很多,这里只插入这两个哈
content_type.insert({".png", "image/png"}); // 如果有需求就通过配置文件去搞
//如果要返回更多形式的文件,比如视频,就继续加
}
~HttpServer()
{
}
bool Start()
{
_listensock.Socket(); // 创建套接字
_listensock.Bind(_port); // 绑定套接字
_listensock.Listen(); // 监听套接字
while (true)
{
std::string clientip;
uint16_t clientport;
int sockfd = _listensock.Accept(&clientip, &clientport); // 获取连接
if (sockfd < 0)
continue;
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this);
pthread_create(&tid, nullptr, ThreadRun, td);
}
}
static std::string ReadHtmlContent(const std::string &htmlpath)
{
std::ifstream in(htmlpath, std::ios::binary); // 以二进制方式来读
if (!in.is_open())
return "";
// 以字符串方式读,传文本还好,传图片视频等二进制数据时,就不行了,所以要在上面ifstream的第二个参数带上std::ios::binary,表示以二进制方式来读
// std::string line;
// std::string content;
// while (std::getline(in, line)) // 第一个是流,第二个是string,从流读到line里面
// {
// content += line;
// }
// C++读取二进制大小
//读取前需要知道文件的大小,先把文件读写位置放到结尾,就可以得到大小,然后再把文件读写位置放到开始
in.seekg(0, std::ios_base::end); // 将文件读写开始位置定位到结尾
auto len = in.tellg(); // 返回文件大小
in.seekg(0, std::ios_base::beg); // 再把文件开始位置返回开头
std::string content;
content.resize(len); //把缓冲区大小调整为文件的大小
in.read((char *)content.c_str(), content.size()); // 二进制读
in.close();
return content;
}
std::string SuffixToDesc(const std::string &suffix) // 把读取到的文件后缀通过映射转化成Content-type所需要的参数
{
auto iter = content_type.find(suffix);
if (iter == content_type.end())
{
return content_type[".html"]; // 没找到默认返回 text/html
}
else
{
return content_type[suffix]; // 返回映射后的参数
}
}
void HandlerHttp(int sockfd) //构建响应,发回去浏览器
{
char buffer[10240];
// man 2 recv 也是可以进行Tcp套接字读取的,它的返回值和使用方法和read几乎一模一样,recv比read多了个参数,flags,标识读取方式,当flags为0时,它的作用就和read一样了
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
// 假设我们读取到的就是一个完整的,独立的http请求
std::cout << buffer << std::endl;
// 读到什么内容就打印什么内容
HttpRequest req;
req.Deserialize(buffer); //解析有效载荷
req.Parse();
// req.DebugPrint();
// 读取成功之后,返回响应
std::string text;
bool ok = true;
text = ReadHtmlContent(req.file_path); // 将指定路径下的文件的内容返回给text
if (text.empty()) // 如果读取的内容不存在,就返回一个404页面
{
ok = false;
std::string err_html = wwwroot;
err_html += "/";
err_html += "err.html";
text = ReadHtmlContent(err_html);
}
std::string response_line;
if (ok)
{
response_line = "HTTP/1.0 200 OK\r\n"; //①添加响应行
}
else
{
response_line = "HTTP/1.0 404 Not Found\r\n";
}
// response_line = "HTTP/1.0 302 Found\r\n"; //当用户访问一个页面时,但是可能这个页面已经弃用,所以使用302状态码。就可以直接跳转到其它的指定地址
std::string response_header = "Content-Length: "; // ②添加响应报头
response_header += std::to_string(text.size()); // 报头添加正文的长度
response_header += "\r\n";
response_header += "Content-Type: "; //图片是二进制的,就是这个响应报文的有效载荷本身是二进制的,所以我们需要告诉浏览器,这个二进制是什么类型的数据,就通过这个参数来告诉浏览器
response_header += SuffixToDesc(req.suffix); // 根据后缀转化为该属性的内容,使浏览器能识别其它后缀的文件
response_header += "\r\n";
response_header += "Set-Cookie: name=haha&&passwd=12345";
response_header += "\r\n";
//response_header += "Location: https://www.baidu.com\r\n";
std::string response = response_line;
response += response_header; //加报头
response += sep;
response += text; // 加有效载荷
//最后到这里后就是一个完整的报文了
// 把消息发回去 man 2 send
send(sockfd, response.c_str(), response.size(), 0);
}
close(sockfd);
}
static void *ThreadRun(void *args)
{
pthread_detach(pthread_self()); // 线程分离
ThreadData *td = static_cast<ThreadData *>(args); // 表示把args的类型强转为THreadData
td->svr->HandlerHttp(td->sockfd); //将响应发回浏览器
delete td;
return nullptr;
}
private:
Sock _listensock;
uint16_t _port;
std::unordered_map<std::string, std::string> content_type;
};
下面是各种函数和成员变量的大致作用:
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <vector>
#include "Socket.hpp"
class HttpServer;
class HttpRequest
{
public:
void Deserialize(std::string req); //将http报头的各种字段分别截取存储起来,并分离请求正文
void Parse(); //对分离出来的请求报文做解析,方便后续服务器处理
public:
std::vector<std::string> req_header; // 请求行
std::string text; // 请求正文
// 存储解析之后的结果
std::string method; //请求的方法
std::string url; //请求的url
std::string http_version; // 请求的http版本
std::string file_path; //访问的文件路径
std::string suffix; // 文件后缀
};
class HttpServer
{
public:
bool Start(); //包含套接字基本流程,创建线程,执行线程函数
static std::string ReadHtmlContent(const std::string &htmlpath); //获取url路径中的文件的数据
std::string SuffixToDesc(const std::string &suffix); //获取资源文件的后缀
void HandlerHttp(int sockfd); //响应主函数,接收反序列化并解析请求,构建响应报头和响应正文,将两个合为http响应报文,并发给客户端
static void *ThreadRun(void *args); //线程函数
private:
Sock _listensock;
uint16_t _port;
};
逻辑梳理:
- 首先服务器收到http请求,首先对其做反序列化,并且解析请求报头中的各个字段
- 然后根据url提供的地址,获取web根目录下的文件大小,内容,后缀,然后都拼成一个长字符串,然后再发给客户端,所以主要逻辑其实很简单