距离上次更新本文已经过去了 350 天,文章部分内容可能已经过时,请注意甄别

已经学习过自己定制一个协议了,现在就来看看当下广泛使用的 http 协议吧

1. 介绍

超文本传输协议(Hyper Text Transfer Protocol,HTTP)是一个简单的请求 - 响应协议,它通常运行在 TCP 之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。

http 和 https 是当下最通用的协议之一,我们访问的大部分网页用的都是这个协议;

plaintext
1
https://www.bilibili.com/

这两个协议主要的差别,那就是 http 是用明文传输数据的,我们的数据在互联网裸奔,可能有安全问题;相比之下,https 传输数据的过程会对数据进行加密,但这也不代表 https 是完全安全的。

1.1 url

要认识这两个协议,我们要从 url 的认识开始;

HTTP(S) 不允许使用用户名或密码,一个合法的 HTTP(S) URL 格式如下:

plaintext
1
http(s)://<host>:<port>/<path>?<query>#<frag>
  • 开头为协议名:http 或 https 协议;
  • <host>:主机名。一个 URL 中,既可以使用域名也可以使用 IP 表示主机地址
  • <port>:端口。主机名和端口之间使用冒号分隔。端口是可选的,如果省略将采用默认端口,http 默认端口是 80,https 默认端口 443;
  • <path>:资源路径。资源在网络主机上的路径,路径也是可选的,缺省访问默认资源;
  • <query>:查询参数。格式为 key=value,多个参数使用 & 分隔;参数也是可选的;
  • <frag>:片段。从 # 开始到最后,一般用于定位到资源内的一个片段,比如文档的一个章节;片段也是可选的。

1.1.1 栗子 ①

接下来举一个具体的例子

plaintext
1
https://blog.musnow.top/2022/08/07/note_linux/6gdb_g++_make_vim/#4-make-x2F-makefile

如上是我的个人博客中的一篇文章

  • 协议是 https
  • 域名是 blog.musnow.top,对应的就是 <host>:<port>;这里隐藏了端口,会在下面说明。
  • /2022/08/07/note_linux/6gdb_g++_make_vim/ 这一长串都是文件的路径,其标示了文件在服务器上存放的位置
  • 这个 url 内没有带 <query> 参数
  • #4-make-x2F-makefile 对应的是 <frag> 片段,标识了我当前浏览的位置

当你把这个 url 粘贴道浏览器,其会直接跳转到对应的标题位置,而不是这篇文章的页首;这就是 <frag> 片段的作用

image-20230215104544655

1.1.2 栗子 ②

plaintext
1
https://www.baidu.com/s?tn=68018901_39_oem_dg&ie=utf-8&word=test

当我们在百度搜索单词 test 的时候,百度的 url 中就会出现 <query> 参数;

其中 ? 是参数的开头,后续的一串以 & 分隔的 kv 键值对,就是参数的内容。在这里面我们能看到 word=test,我们查询的单词 test 就在这个参数后;

我们的搜索访问,就是将这些参数传送给服务器,再由百度的服务器返回搜素结果的。

1.2 域名和端口隐藏

但我们日常访问的网页中,很少见到过 ip:端口的形式,而大多是用域名为我们提供服务的

plaintext
1
https://www.bilibili.com/

这并不代表其背后不需要端口号。而是因为如果我们的访问不指定端口的时候,http (s) 协议会采用默认端口号 80 或 443,从而实现隐藏端口号提供服务

http 默认端口是 80,https 默认端口 443

毕竟对于用户而言,记住一个域名已经不容易了,还要记住你的服务是在哪一个端口,那就更难了;

而域名也不是凭空给我们提供服务的,每一个域名都需要绑定一个具体的公网 ip(域名解析),才能为用户提供服务。在域名的背后,都是一个 ip,每一个 ip 也就是一台服务器。

域名的作用,就是来隐藏掉 ip 这个无规律的长数字,方便用户访问;

你觉得是记住 baidu.com 容易,还是记住 114.514.77.58 容易呢?

在命令行使用 ping 工具,我们能知道一个网站服务器的 ip 是什么

bash
1
ping www.bilibili.com

比如我们 ping 一下 b 站的域名,可以看到其公网 ip 是 183.131.147.29

plaintext
1
2
3
4
5
6
7
8
9
10
正在 Ping a.w.bilicdn1.com [183.131.147.29] 具有 32 字节的数据:
来自 183.131.147.29 的回复: 字节=32 时间=10ms TTL=55
来自 183.131.147.29 的回复: 字节=32 时间=12ms TTL=55
来自 183.131.147.29 的回复: 字节=32 时间=12ms TTL=55
来自 183.131.147.29 的回复: 字节=32 时间=12ms TTL=55

183.131.147.29 的 Ping 统计信息:
数据包: 已发送 = 4,已接收 = 4,丢失 = 0 (0% 丢失),
往返行程的估计时间(以毫秒为单位):
最短 = 10ms,最长 = 12ms,平均 = 11ms

1.3 ip: 端口

我们可以用 ip:端口来访问自己的服务(以下 ip 纯属虚构,如有撞车,纯属巧合)

plaintext
1
114.514.77.58:8080

当我们把这个粘贴道浏览器,再复制粘贴出来的时候,我们会发现前面多了一个 http

plaintext
1
http://114.514.77.58:8080

这是因为当我们使用 ip 访问一个服务的时候,浏览器会默认采用 http 的协议去访问,所以在前面加了一个我们看不到的 http://

1.4 协议作用

http 协议的作用,就是向服务器申请特定的资源,再将资源拉取到本地进行展示 or 使用。

资源都是存在我们的服务器上的,当用户请求的时候,服务器必须要知道资源的路径,将其 read 打开读取,再 write 写给我们的客户端。

plaintext
1
/2022/08/07/note_linux/6gdb_g++_make_vim/

所以 http 的请求中才会带上资源的路径,这是方便服务器进行资源文件的读取;同时,文件的路径也是对一个文件的唯一标识,在告诉服务器文件路径的同时,也保证了我们请求的文件的唯一性,不会出现二义性;


这时候又会出现一个问题,当我们访问网站的根目录的时候,没有提供文件的路径呀,那这时候,访问的什么文件呢?

plaintext
1
https://www.baidu.com/

实际上,我们访问的是服务器根目录的 index.html 文件

plaintext
1
2
https://www.baidu.com/
https://www.baidu.com/index.html

你可以试着打开这两个链接,其出现的页面是完全一致的;

image-20230215110843400

类似于端口号隐藏,http 协议也确定了当下使用的网页文件的命名为 index.html,当我们访问一个网站的时候,就会默认访问根目录下的 index.html 文件(既然是默认的,那就可以直接隐藏)如果这个文件不存在,那就不会渲染出我们看到的网页!

plaintext
1
index.html是用前端语言编写的网页代码

同理,当我们访问博客的时候,读取道的也不是目录,而是目录下的 index.html 文件

plaintext
1
https://blog.musnow.top/2022/08/07/note_linux/6gdb_g++_make_vim/

image-20230215111050026

我的博客使用的是 hexo 框架,其网页的源路径在 github 上开源了,可以帮助你理解 url 中的文件路径。

当前你看到的文件目录,就是博客服务的根目录。访问的博客首页,就是根目录下的 index.html

image-20230215111147560

用作示例的 linux 工具使用博客,也可以根据它的路径,找到 index.html 文件

plaintext
1
/2022/08/07/note_linux/6gdb_g++_make_vim/

image-20230215111416443

这便是 http 协议 url 中文件路径的作用!

这里的 / 根目录是服务端设置的,并不一定是(大概率不是)服务端 linux 服务器的根目录

hexo 博客已更新为绝对数字路径,本文中演示的路径已经无法访问

1.5 编码解码

在 url 中,还会对一些特殊字符进行编解码,比如中文,和一些特殊的符号

plaintext
1
https://blog.musnow.top/2022/08/07/note_linux/6gdb_g++_make_vim/#4-make-x2F-makefile

比如在作为示例的 url 中,这里出现了 x2F,而原文中是 4.make/makefile; 这里的编码就是为了避免 make/makefile 被识别成路径的标识符,从而出现错误。

plaintext
1
https://www.baidu.com/s?tn=68018901_39_oem_dg&ie=utf-8&word=%E4%BD%A0%E5%A5%BD

当 url 路径中有中文的时候,也会被转码成特定的格式

image-20230215112344227

我们在浏览器上看到的依旧是中文,这是因为浏览器这段帮我们进行了解码

将需要转码的字符转为 16 进制,然后从右到左,取 4 位 (不足 4 位直接处理),每 2 位做一位,前面加上 %,编码成 %XY 格式

2.http 协议请求格式

了解了 http 协议中的 url 网址,现在就可以进一步了解 http 协议的报头和报文了;

一个 http request/response 的基本格式如下

image-20230215113707995

在请求中,客户端会提供自己的请求方法(GET/POST/PUT 等等),并提供 url 来标识自己需要的文件路径;这个 url 可能是短链接(截取根目录之后的部分),也有可能是完整的链接。

随后,就会跟上一大堆的请求参数,注意,这里的请求参数并不是 url 中的 <query> 参数,而是 http 协议自身的请求参数。每一个请求参数都用了 \r\n 作为分隔,这和我们写的自定义协议中采用 \t 进行分割是相同的原理!

这几个部分中,请求的正文可以不带(为空)

2.1 读取多长?

为了让协议读取的时候,能够知道什么时候读取完毕了报头,http 协议提供了一个 \r\n 的空行,读取道这个空行,就代表报头读取完毕了,剩下的内容都是报文。

而为了标识报文的长度,http 协议会在发送的时候提供一个参数 content-length,用于标识报文的长度。在读取完毕报头后,肯定是读取到了这个 content-length 参数的,也就知道后续应该继续读取多长,才能读完整个协议字段!

关于这部分的介绍,可以查看另外一篇博客 http 协议 content-length 详解

2.2 响应的状态码

和我们进程的退出状态一样,http 也表明了一部分响应的状态码,其中我们日常最常见到的,是 404/403 这两个状态码

http 状态码 - 百度百科

状态码就是标识服务器提供的服务状态,告诉客户端它的请求是否成功了。如果状态码是 200,代表请求是成功的。其余状态码会有各自的使用场景,比如 404 状态码,代表请求的资源不存在,所以才叫 404 not found!

3. 实例

完整代码详见 Gitee

3.1 前端页面

由于本人并没有学习过前端语法,这里采用 菜鸟教程 提供的前端示例代码来演示

html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>菜鸟教程(runoob.com)</title>
</head>
<body>

<h1>我的第一个标题</h1>
<p>我的第一个段落。</p>

</body>
</html>

我们日常所访问的网页都是这样的代码,经由浏览器进行渲染,再展示出来

3.2 服务端代码

由于 http 是基于 tcp 的,这里直接把之前写的 tcpServer 搬过来就能用!具体的代码解析请看注释,想必说的是很清楚的

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#define CRLF "\r\n"
#define CRLF_LEN strlen(CRLF)
#define SPACE " "
#define SPACE_LEN strlen(SPACE)

#define HOME_PAGE "index.html" // 首页文件
#define ROOT_PATH "web" // 网址根目录地址

// 获取http请求中的路径
string getPath(string http_request)
{
size_t pos = http_request.find(CRLF);//找到第一行的分隔符
if(pos == string::npos)
return "";
string request_line = http_request.substr(0, pos);//取出第一行
//请求的第一行:GET /a/b/c http/1.0
size_t first = request_line.find(SPACE);// 找到第一个空格
if(pos == string::npos)
return "";
size_t second = request_line.rfind(SPACE); // 从后往前找空格
if(pos == string::npos)
return "";
// 找到两个空格了,两个空格之间的就是请求的路径
string path = request_line.substr(first+SPACE_LEN, second - (first+SPACE_LEN));
// 对path进行判断,如果path是以/结尾的,则在path中追加index.html文件名
if(path[path.size()-1] == '/') {
path += HOME_PAGE; //加上被隐藏的index.html文件名
}
return path;
}
// 读取文件
string readFile(const string &recource)
{
ifstream in(recource, ios::binary);
if(!in.is_open()) //文件打开失败
{
return "404";
}
// 内容
string content;
string line;
while(getline(in, line))
{
content += line;
}
in.close();
return content;
}
void handlerHttpRequest(int sock)
{
cout << "###########start#############" << endl;//打印一个分隔线
char buffer[10240];
ssize_t s = read(sock, buffer, sizeof(buffer));
if(s > 0){
cout << buffer << endl;
cout << "###########end############" << endl;
}
string path = getPath(buffer);
// 假设用户请求的是 /a/b 路径
// 那么服务端处理的时候,就需要添加根目录位置和默认的文件名
// <root>/a/b/index.html
// 在本次用例中,根目录为 ./web文件夹,所以完整的文件路径应该是
// ./web/a/b/index.html

string resources = ROOT_PATH; // 根目录路径
resources += path; // 文件路径
logging(DEBUG,"[sockfd: %d] filePath: %s",sock,resources.c_str()); // 打印用作debug

string html = readFile(resources);// 打开文件

// 开始响应
string response = "HTTP/1.0 200 OK\r\n";
//如果readFile返回的是404,代表文件路径不存在
if(strcmp(html.c_str(),"404")==0)
{
response = "HTTP/1.0 404 NOT FOUND\r\n";
}
// 追加后续字段
response += "Content-Type: text/html\r\n";
response += ("Content-Length: " + to_string(html.size()) + "\r\n");
response += "\r\n";
response += html;
// 发送给用户
send(sock, response.c_str(), response.size(), 0);
}

3.3 测试

启动服务器之前,请先打开你的云服务器防火墙中的对应端口;这里我绑定的是端口 10000,在浏览器中用 ip:端口的方式可以正常访问!

image-20230215133459929

这里标识的不安全是因为我们没有采用带加密的 https 协议,这不是当下需要考虑的问题。不管他就可以了。

按 F12 打开开发者页面,可以看到下方出现了完整的 html 代码,我们成功提供了服务!

image-20230215134751216

3.4 后端打印的报文

在服务器后端,我们看到其打印出来了一个基本的 http 请求,和上面说明的格式是一样的。这里简单的进行一部分说明:

  • GET:请求方式为获取数据
  • /:请求的是根路径
  • HTTP/1.1:使用的 http 协议版本
  • Connection:代表我们和服务器的链接方式,keep-alive 代表保持连接
  • User-Agent:客户端信息,可以看到是 windows 系统、Chrome 内核的浏览器(我是用的是 edge 浏览器)
  • Accept: 支持接收的信息类型
  • Aceept-Encoding: 对信息进行压缩
  • Accept-Language:支持的语言
  • Cookie:身份信息,后面会详细介绍

其中出现了一个空行,代表报文结束;

image-20230215141623151

往下滑,会发现浏览器还发出了第二个请求,路径是 /favicon.ico,这是默认的站点头像文件的命名。因为我们的 html 文件中没有写明站点头像的路径,所以浏览器就尝试请求默认的头像文件

但是,当前我们的站点根目录 web 下并没有该文件,应该返回一个 404 状态码。

  • 请求中出现了一个新的参数 Referer,代表是从当前网页请求头像的。相比之下,请求网页的报文中没有 Referer 参数

image-20230215153442298

此时可以随便找个图片做头像,看看能不能加载出来;为了方便,我随便找了一张纯绿色的图片,并将其在线转换为 ico,放入了站点的根目录。

重启服务器进程,刷新浏览器再次请求,可以看到成功出现了站点的头像;

image-20230215140232033

3.5 常见参数表

img

4. 请求方法

一般我们获取一个网页,用的都是 GET 方法。接下来用一个带按钮的表单创建请求,尝试向服务端发送 <query> 参数

html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>菜鸟教程(runoob.com)</title>
</head>
<body>

<h1>我的第一个标题</h1>
<p>我的第一个段落。</p>

<form action="/a/index.html" method="get">
Username: <input type="text" name="user"><br>
Password: <input type="password" name="passwd"><br>
<input type="submit" value="Submit">
</form>

</body>
</html>

4.1 GET

在 method 里面,我们指定了 get 方法,此时

html
1
2
3
4
5
<form action="/a/index.html" method="get">
Username: <input type="text" name="user"><br>
Password: <input type="password" name="passwd"><br>
<input type="submit" value="Submit">
</form>

此时页面中出现了两个输入框,供我们输入密码,且密码会显示为 **** 而不是明文

image-20230215152726050

点击按钮,会跳转到一个 404 页面,这是因为我们的 a/index.html 路径并不支持参数请求,所以发送了 404 错误码

image-20230215152834203

不过这不重要,我们看看后端打印的内容。其中参数是追加到 url 中,以明文传输过来的;正文部分为空,并没有携带参数

image-20230215153559515

4.2 POST

将请求方法改成 post,再次尝试

html
1
2
3
4
5
<form action="/a/index.html" method="post">
Username: <input type="text" name="user"><br>
Password: <input type="password" name="passwd"><br>
<input type="submit" value="Submit">
</form>

这时候能正常显示出 a/index.html 页面,url 中不再带有参数

image-20230215153820898

此时查看后端中的信息,能看到请求方法变为 POST,参数出现在了正文部分,而不是 url 中

image-20230215153928118

4.3 GET/POST 区别

这也是 GET 和 POST 方法的区别之一:

  • GET 方法通过 url 传参
  • POST 方法会将 url 参数提取出来,拼接到正文部分

由此能推出二者的安全性区别

  • GET 方法相对不安全,因为参数直接以明文贴在了 url 上
  • POST 方法以正文传参,使用 https 协议的时候能进行加密,相对较安全

4.4 更多方法

http 请求还支持更多方法,如下

img

5. 状态码

5.1 状态码表

下面是一个响应状态码的总表

image-20230215155118813

5.2 404/403 状态码

我们能看到 404 和 403 都是客户端状态码,为什么说是客户端错误呢?这是因为你向服务器请求了一个服务器没有的资源,这个问题不应该是服务器的问题

这就好比你去西瓜摊买肉,人家压根不卖肉。问题出在你身上,你不应该找西瓜摊老板买肉。所以 404 状态码,应该是客户端的问题!

而 403 状态码的含义是 403 forbidden,服务器拒绝了你的请求(你没有权限访问)这也是客户端的问题。你去超市买东西,然后问老板能不能让你看看老板的钱罐子。老板肯定不给你看啊!所以才会告诉你 403,不给你访问。

5.3 5xx 状态码

什么时候会出现服务器错误的 5xx 状态码呢?

当你请求一个服务,服务端需要处理之后给你返回结果。此时服务器在处理过程中,可能因为程序有 bug 提前退出,这时候就应该给客户端发送一个 5xx 状态码,标识服务器在处理你的请求的过程中,出现了错误,无法返回结果。

5.4 301/302 重定向

关于 3 开头的状态码,主要谈谈下面这两个

plaintext
1
2
301 永久重定向
302 临时重定向

为何一个是永久,一个是临时呢?

在 http 进行响应的时候,服务端可以发送一个 location 参数,发送一个新的 url 给客户端;我们的浏览器在收到这个参数后,会自动打开对应的页面

plaintext
1
Location: https://www.baidu.com/

我们可以用上面的代码来测试一下

cpp
1
2
3
4
5
6
7
// 尝试进行302重定向
string response = "HTTP/1.1 302 Temporarily moved\r\n";
response+= "Location: https://www.baidu.com/\r\n";
response+= "\r\n";

// 发送给用户
send(sock, response.c_str(), response.size(), 0);

此时访问我们自己的 IP:端口,会跳转到百度的页面。这就是重定向的作用

image-20230215161520817

  • 那临时重定向和永久重定向有什么区别呢?

假如我设立了一个公开站点,域名是 test.com,这几年一直提供服务,积累了一部分的用户。过了一会,我不想要这个域名了,想换一个 test.cn;可用户已经有那么多,大家都只记得你的 test.com,如果直接更换域名,就会导致用户无法访问 test.com,以为你跑路了,就放弃使用你的网页。

这样的结果显而易见:严重的客户流失!

所以,为了避免这个问题,我可以先将服务迁移到 test.cn 新域名,在旧域名 test.com 中设置一个 301 重定向到 test.cn,告诉用户我换新域名了。这样持续一段时间后,等到 test.com 的使用者不多了,就可以考虑彻底取消 test.com 的解析,关停此域名了。

在上面的场景中,我是需要更换域名,是永久更换。我们就应该把状态码设置为 301,告诉客户端这个域名将被永久重定向到另外一个域名上


另外的情况是,我有一个 example.com 域名,我的服务需要进行维护;此时就将 example.com 重定向到另外一个域名 example.cn,指向另外一个服务器,让这个服务器临时提供服务。

服务维护完毕后,就将重定向取消,继续使用当前的服务器。

这个场景中,重定向只是维护期间的一个临时策略,所以状态码设置成 302,告诉客户端我只是临时进行重定向,我还会回来的。

6.cookie/session

日常生活中,当我们在一个 web 页面中登录了(如 github/gitee/csdn)这个网页在很长一段时间内都会保持登录,直到超时亦或者是出现了异地访问。

假如你现在有个网页,但是每次用户访问的时候,都需要重新输入用户名和密码,刚刚输入过了,换一个页面又不行了。这样一来,用户还会想用你这个服务吗?

为了避免此类问题,http 协议就需要引入其他的参数,来维持用户的登录会话。cookie/session 便是因此而来的。

服务端可以在响应头中带上 Set-Cookie 字段,给客户端设置上 cookie

cpp
1
response += "Set-Cookie: This is my cookie test\r\n";

打开 f12 开发者界面,能在其中看到我们设置的 cookie

image-20230215171652910

再次刷新网页,可以看到在之后的请求中,浏览器都会发送一次服务器设置的 cookie。这样服务端在收到 cookie 后,就能解析到自己设置的 cookie,确定了指定的用户

image-20230215171805924

在服务器端也能看到这个字段

image-20230215172640992

对于 cookie 来说,其还有一个路径的配置项。见下图,我在实现我的视频点播系统的时候,在 /usr/login 处直接用了如下 header 来 set-cookie

cpp
1
2
3
rsp.set_header("Set-Cookie","sid=123456");
// 如上语句是httplib,等价于
// Set-Cookie: sid=123456\r\n

image-20230814105000336

这就导致我对我的视频点播其他界面中的操作并没有携带上我自己 set 的这个 cookie,也就没有办法实现后续的 seesion 识别(这里我写死了是因为还在初始测试阶段)

我们要做的就是在 cookie 后面携带一个 path,来告诉浏览器这个 cookie 应该是全局的

plaintext
1
Set-Cookie: sid=123456; path=/

这样才能实现后续整个网站的请求都会带上这个 cookie,否则只有请求 /usr 开头的路径才能带上这个 cookie

image-20230814105357134

如上图,这样设置了后,cookie path 已经是 / 代表根路径了

image-20230814105422129

6.2 什么是 cookie

所谓 cookie,其实就是浏览器帮我们存取了一定的身份信息在本地(内存 or 磁盘)

下一次打开特定的网页的时候,就能显示对应的身份信息(不一定是你的账户密码),并告诉服务器,服务器就识别到了你当前的用户,并为你保持登陆状态。

既然是保存在用户本地的,那就有可能被窃取。一些恶意软件就会去扫描你浏览器本地缓存中的 cookie 信息,对于一些安全性不高的网站而言,有了这个 cookie,就相当于他有了你的账户,可以直接登录你的账户进行操作。

因此,引入了另外一种身份认证的方式 cookie+session

比起将身份信息存到客户端,存至服务端更为安全(攻击企业服务器的成本,比在用户端植入木马程序的成本更高)

  • 用户使用账户密码请求登录,服务器收到登录请求,验证成功后,给客户端返回一个唯一字符串session_id 来标识用户
  • 客户端下一次请求的时候,带上了这个唯一字符串
  • 服务器收到请求,在本地的 session_id 库中查找这个 id,找到后,就将用户信息匹配给客户端,相当于客户端登录成功了

这样,就将原本存在用户本地的身份认证信息,存到了服务端中。客户端就只剩下一个孤零零的字符串 id,不会有用户的私密信息。即便丢失,也不会影响用户的隐私。

  • 你可能会说,那我偷走这个 id 不也是一样的效果?

其实没有那么简单,服务端可以将 session_id 和用户的 ip 或者终端 User-Agent 绑定,这样只要用户切换设备或者换了登录的地点(比如从三亚跑到了哈尔滨)就直接让 session_id 失效,要求用户重新登录。

7. 长短链接

在早期的 http 协议中,采用的都是短链接,一次连接只能处理 1 次 http 请求。当时的网页大多以文字为主,数据量很小,一起请求也能够满足需求。

但现在时代已经变了,一个网页里面有图片,文字,音频,视频。这些文件的体积打起来之后,短链接的方式就不适用了。此时就出现了长链接,一次 tcp 链接,可以持续传输数据。

相比短链接,长链接连上之后,能持续传输数据,避免了 tcp3 次握手的消耗,提高了数据传输的效率!

7.1 Connection

在本文的 3.4 中,便出现了这个参数,一般情况下,会有下面两种情况

plaintext
1
2
Connection: keep-alive
Connection: closed

其中 keep-alive 就是长链接,closed 代表当前端口只支持短链接。

当客户端发送的请求头中包含 Connection: keep-alive 字段,如果服务器支持长链接,就需要在响应头中也带上 Connection: keep-alive,这样双方协商成功,大家都可以使用长链接。

如果服务器的响应头中没有带 Connection: keep-alive,那么客户端就会认为服务器不支持长链接,下次请求的时候,会重新向服务器链接,再获取资源。

如果客户端和服务端任意一方的响应头中包含 Connection: closed,那么就会认为当前的会话只支持短链接,下次请求会重新建立链接。

7.2 http 和 tcp 的关系

http 虽然是基于 tcp 的,但 http 本身是无链接的。

举个最明显的栗子,在你打开一个网页之后,你关闭掉自己的 wifi,你的网页并不会因此消失。只是无法进行后续操作而已。

http 是一个无链接的应用层协议,其借助 tcp 进行数据的流式传输,但不一定需要客户端和服务端保持连接。

所以,http 就可以借助单个 tcp 套接字持续的传输数据,也就天然地支持了长链接通信。

总结一下,http 只是借用了 tcp 的能力,其无连接的特性和 tcp 没有关系!

7.3 pipeline

这其中会牵扯到一个 pipeline,其维护了 http 长链接请求时的响应顺序

比如人家需要加载一个网页,服务端应该先把网页的整体框架给用户加载出来,再给用户加载图片、视频、音频等资源。下图 B 站的加载就是一个很好的栗子。

image-20230216213625496

否则乱序了,比如只出现了一个孤零零的图片,就会让人感觉非常奇怪。

8.https

因为 http 的数据是无加密明文发送的,相对来说并不是非常的安全;为了实现数据加密,https 在 http 的下层添加了一个 SSL/TLS 软件层,来进行数据加解密工作

image-20230215193931213

8.1 为啥要加密?

要知道,所有的加密工作,都是为了防止数据在中间传输的过程,被窃取或修改。如果我们请求一个网站登录的时候,数据包中就会包含我们的账户密码。如果被窃取,我们的隐私就泄露了。这是很难受的一件事!

这也是为什么,我们经常能听到免费公共wifi不安全这一说法,因为我们在这个 wifi 上进行的所有数据交换,都会走这个 wifi 的路由器,很容易被中间人窃取并获取到我们的数据包。

这种情况下,https 的加密就更有必要了!

但是,加密解密是需要时间的,所以 https 响应的速度会稍慢于 http。不过当下 cpu 的执行速度已经非常快,这点时间差距很小,不会特别影响我们的日常使用!

不过,加密并不是一个万金油,并不是说加密了之后的数据就一定能避免被窃取。但加密可以大大提高窃取破解的成本,无形中降低了数据被窃取的概率,保证了一定的数据安全。

8.2 常见加密方式

8.2.1 对称加密

所谓对称加密,好比有一个带锁的盒子,客户端和服务器都有一把钥匙。客户端先把信息丢进盒子里,再用🔑锁上盒子,发送给服务端。服务端用🔑打开盒子,取出数据。

因为客户端和服务器持有的钥匙是完全一致的,所以被称为对称加密。在加密的场景下,钥匙一般被称为密钥

在网络场景里,对称加密是不可取的。只要客户端和服务端传输密钥的时候被窃取,那么双方的加密就失效了。因为是用同一个密钥来加密解密,我拿走了你的钥匙,自然就能打开你这把锁。

也就是说,密钥的传输也需要加密。但是这又引出一个问题,我都没有你的密钥,我怎么解密你发过来的密钥信息?这是一个先有蛋还是先有鸡的死循环!

8.2.2 非对称加密

非对称加密场景下,会有一个公钥和私钥

  • 私钥对数据加密,变成密文
  • 公钥对数据解密,变成明文

二者也可以反过来

  • 私钥对数据解密
  • 公钥对数据加密

其中最常用的非对称加密,也就是我们在 git 的 ssh 操作中使用过的 rsa 密钥,其中就有一个.pem 公钥和一个私钥。我们将公钥提供给 github,私钥保存到本地,就能实现无密码上传数据到 git 仓库。


但是非对称加密还是会存在中间人攻击的问题。先看如下图,你应该能发现,其中有一个重要的环节,就是服务端要把公钥发送给客户端

image-20230216204344555

在这个场景中,公钥是公开传输给客户端的,也就是后续服务器发送给客户端的所有信息,都可以被其他人用这个公钥解析出来;

整个环节中,只做到了客户端发送给服务器的信息安全,因为只有服务器拥有私钥,能解密出数据。(单项数据安全)

8.2.3 双非对称

这时候,我们可以采用双非对称密钥加密的方式!既然非对称只能保证单方的数据安全,那使用两个非对称,不就能保证双方数据安全了嘛!

  • 客户端和服务端交换公钥 a' 和 b'
  • 客户端给服务端发信息:先用 a' 对数据加密,再发送;只能由服务器解密,因为只有服务器有私钥 a
  • 服务端给客户端发信息:先用 b' 对数据加密,再发送;只能由客户端解密,因为只有客户端有私钥 b

image-20230216210830169

因为两份私钥都只有客户端和服务器自己拥有,所以黑客没有办法进行数据的窃取,也就保证了数据的安全。

即便中间人替换了交换的公钥,也会因为后续的通信,客户端 or 服务端本地的私钥无法正常解密,而发现数据被窃取!

但是但是,这样左还算有很大的缺点

  • 效率太低(非对称加密解密负载太高,效率低下,特定场景下无法满足要求)
  • 依旧可能存在安全问题

8.2.4 非对称 + 对称

  • 服务端具有非对称公钥 S 和私钥 S’
  • 客⼾端发起请求,获取服务端公钥 S
  • 客⼾端在本地生成对称密钥 C, 通过公钥 S 加密,发送给服务器.
  • 由于中间人没有私钥,即使截获了数据,也无法还原出内部的原文,也就无法获取到对称密钥
  • 服务器通过私钥 S' 解密,还原出客户端发送的对称密钥 C,并且使用这个对称密钥加密给客户端发送的响应数据
  • 后续客户端和服务端都采用密钥 C 来进行对称加密通信

因为对称密钥 C 在传输过程中是加密的,只有客户端和服务端知道密钥是什么,也就实现了数据的安全通信!

8.3 数据摘要(指纹)

数字指纹 (数据摘要), 其基本原理是利⽤单向散列函数 (Hash函数) 对信息进行运算,生成⼀串固定长度的数字摘要。

常见的摘要算法有 MD5/SHA1/SHA256/SHA512 等;

数字指纹并不是⼀种加密机制,但可以用来判断数据有没有被窜改,亦或者是下载的数据包有没有出现损坏。

  • 同一个数据文件,用同一个方法生成的数据摘要是一致的
  • 不同文件生成的数据摘要可能会撞车,但几率极低,可以认为具有唯一性!
  • 我们无法用数据摘要反推出数据内容(怎么可能用一个字符串推测出原本的内容呢?那样还存放源文件干哈?😂)

数据摘要在网盘产品中也有使用,当我们使用百度云盘、阿里云盘的时候,会遇到一个大的资源文件只用了短短几秒就成功上传到服务器的情况。此时,我们并不是真的用几秒就把数据传输上去了,而是经历了以下阶段

  • 网盘客户端对本地文件生成数据摘要
  • 生成后,判断服务器端已有文件中,是否有同该数据摘要相同的文件
  • 如果有,代表该文件已经存在了云盘的服务器中
  • 服务器将该文件给你的账户建立一个软链接 / 硬链接,就实现了 "妙传"
  • 如果没有,则老老实实的从本地上传文件到云盘

云盘厂家这么做的原因很简单:避免同一份文件被多次存储。当下网盘给用户的免费空间动则上 T,如果所有文件都重复保存,那对于云服务器厂家来说,资源消耗太大了。

这个做法并不会产生数据隐私问题,一般只有电影等资源文件才有可能妙传成功。你可以使用一些 “其他手段”,比如把资源打个压缩包,并在压缩包中随便丢另外一个文件,让文件的数据指纹和已有资源不相同,就不会进行妙传了。

这样做还有另外一个好处,那就是原资源因为违规被 ban 的时候,你的资源不会被连坐😂

8.4 数字签名

对数据摘要进行加密,生成的内容被称为数字签名

8.5 中间人攻击

中间人攻击(Man-in-the-MiddleAttack),简称 MITM攻击

8.2.2 的单非对称加密为例,中间人可以在整个过程中进行偷梁换柱,窃取双方的信息。如下图:

image-20230216205047679

透过这个栗子🌰,实际上,如果中间人在客户端和服务端开始通信之前就来窃听并准备换柱了,他就有可能替换双方密钥,从而解密双方发送的信息!

最重要的一点,是客户端 or 服务端都没有办法证明,当前的公钥是直接从服务端 or 客户端发来的,它们没有办法检验公钥的权威性,只能被动接受。由此给中间人偷梁换柱提供了可能。

这时候,就需要引入 CA 机构和 CA 证书了👇

8.6 CA 证书

所谓 CA 证书,是由 CA 机构颁发的权威证书。CA 机构在颁发证书时,会在证书中附带上该站点的域名,以及申请人(企业)的相关信息

  • CA 机构会有一个自己的私钥和公钥,其公钥向所有人公开
  • CA 机构的私钥由其自己保存(私钥一定不能泄露)
  • 当下的浏览器、操作系统都会内置认可的 CA;只有被认可的 CA,才能为站点提供 ssl 证书服务

在我们 windows 本地就能看到当前操作系统认可的 CA 机构其公钥;在 edge 浏览器中,点击右上角选择,进入设置,在选择隐私页面,找到管理证书

image-20230216222946008

点击它,就能看到当前本地认可的 CA

image-20230216223131725

8.6.1 ssl 证书加密原理

当一个站点获取了 ssl 证书后,在向用户发送 ssl 证书中包含的公钥的同时,还会发送一个由 CA 机构对 ssl 证书公钥做的数字签名

  • ssl 证书公钥的数字签名 A,通过 CA 机构的私钥进行加密
  • ssl 证书的公钥 B

当客户端收到这份信息之后,会采用 hash 函数对收到的 ssl 证书公钥进行数字签名,得到一个本地生成的数字签名 C

再用 CA 机构的公钥对传输过来的数字签名 A 进行解密,得到数字签名 A 的明文;判断由 CA 机构生成的 ssl 公钥数字签名 A 是否和本地生成的数字签名 C 相同;

  • 如果相同,则代表证书正确!
  • 不相同,代表证书出现错误!

画个图,大概就是下面这样

image-20230216221611664

由于当下发送的数据包中,同时存在 ssl 公钥的明文 + 由 CA 机构加密后的数字签名,中间人无法进行任何攻击修改!

  • 若修改 ssl 公钥,由于中间人没有 CA 机构的私钥,无法对修改后的 ssl 公钥生成对应的加密后数字签名
  • 若使用 CA 公钥解密数字签名后修改…… 依旧会因为没有 CA 机构私钥,无法把修改后的签名加密回去
  • 如果中间人用自己的私钥生成一个数字签名,但我不认识你这个 CA,也不知道你的公钥是什么,怎么解密你的信息呢?

综上,中间人要想偷梁换柱,只有一个办法了,那就是拿一个真的证书整体替换掉这个数据包。

可是 ssl 证书中还包含了域名、站点主体等各类信息,我当下访问的是 baidu.com,结果收到的证书是 qq.com 的,那肯定有问题啊!浏览器会直接拒绝访问!😂

当我们访问一些网站,浏览器报 ssl 证书过期,也是会出现一定的安全问题的!如果一个网站没有使用 https,那么在这个网站上进行用户登录等敏感操作的时候,一定不要设置和你其他平台相同的密码!

当然,如果某个网页本来就只是提供文件公开下载功能的,比如下载 linux 系统的 iso 镜像,那么它不套用 https 也是情有可原的,因为压根没有必要!

8.6.2 ssl 证书 + 非对称 + 对称

有了上面这个不能被篡改的 ssl 证书公钥,下面我们就可以利用非对称+对称加密的方式进行通信了

  • 客户端收到 ssl 证书,向服务器发送一个本地生成的密钥 D(使用 ssl 证书公钥进行加密)
  • 服务端收到密钥 D 的加密信息,使用 ssl 证书的私钥进行解密,获取到密钥 D
  • 客户端和服务端使用密钥 D 进行对称加密通信

这样即解决了安全问题,又规避了非对称加密的效率问题,一举多得!

8.7 SSL 握手过程

引用:https://zhuanlan.zhihu.com/p/58955297

image.png

具体流程:

第一步:客户端向服务器发起请求

1、客户端生成随机数 R1 并且发给服务器端。

2、告诉服务器自己支持哪些加密算法。

第二步:服务器向客户端发送证书

1、接收到客户端的数据后,服务端生成随机数 R2。

2、从客户端支持的算法中,选择一个服务器支持的算法,作为进行会话的密钥算法。

3、发送证书(证书公钥 [申请证书时,会有公钥给服务器]、企业信息、域名过期时间、摘要算法)给客户端。

第三步:协商共享密钥

1、接受服务器发送的数据:证书的公钥、会话密钥生成算法、随机数 R2。

2、验证证书的可靠性,先用 CA 的公钥解密被加密过后的证书,能解密则说明证书没有问题,然后通过证书里提供的摘要算法进行对数据进行摘要,然后通过自己生成的摘要与服务端发送的摘要比对。

3、验证证书合法性,包括证书是否吊销、是否到期、域名是否匹配,通过后则进行后面的流程。

4、生成随机数 R3。

5、根据密钥算法使用 R1、R2、R3 三个随机数生成共享密钥(此时客户端已经得到完整的三个随机数)。

6、用服务端证书的公钥加密随机数 R3 并发送给服务端。

第四步:服务器得到用于进行会话的密钥

1、服务器通过私钥解密客户端发过来的随机数 R3(此时服务端已经得到完整的三个随机数)。

2、根据会话的密钥算法使用 R1、R2、R3 生成会话密钥。

第五步:客户端与服务端进行加密会话

1、客户端发送加密数据发给服务器端。

2、服务器响应客户端。

解密接受的密文数据:服务器端用会话密钥解密客户端发送的数据。

加密响应的数据:用会话密钥把响应的数据加密发给客户端。

3、客户端接受服务器端响应的数据

解密数据:客户端用会话密钥解密响应数据。

9.Content-Type

9.1 问题

之前写的 http 服务器有一个很大的弊端,就是 Content-Type 没能做到根据文件的格式进行自定义修改

默认情况下,如果 index.html 中没有指定 icon 的路径,浏览器会自动请求根路径下的 favicon.ico,如果没有这个文件,则不显示站点图标。

如果在 index.html 有指定站点 icon 的路径,则会请求对应路径的图片。

如下图,我在 index 中指定了 icon 的路径

html
1
2
3
4
5
<head>
<meta charset="utf-8">
<title>http协议学习</title>
<link rel="shortcut icon" href="https://img.musnow.top/i/2022/12/icon.png">
</head>

但是浏览器去请求的时候,返回的文件类型依旧是 text(因为已经写死了)

image-20230325154201646

最终结果就是,配置的 icon 无效,依旧不显示站点图标

image-20230325154233852

所以,我们应该在服务端给返回的文件添加上正确的 Content-Type

9.2 代码

为了访问支持多种文件类型,我在 tcpServer 的类中新增了一个 map,用于文件后缀和图标类型的对照。每每看到这个场景,我都想感慨一下:Python 的 dict 还是方便多了🤣

cpp
1
2
3
4
5
6
7
8
9
10
11
void initMap()
{
_fileTypeMap.insert({"html","text/html"});
_fileTypeMap.insert({"jpg","image/jpeg"});
_fileTypeMap.insert({"jpeg","image/jpeg"});
_fileTypeMap.insert({"png","image/png"});
_fileTypeMap.insert({"gif","image/gif"});
_fileTypeMap.insert({"ico","image/x-icon"});
}
// 文件类型和http响应头的对照表
map<string,string> _fileTypeMap;

在设置 Content-Type 的时候,先从 path 中分离出客户端请求的文件类型

cpp
1
2
3
4
5
6
7
8
9
10
11
12
// 获取文件的后缀
string getFileType(const string& path)
{
size_t i = path.rfind('.');
if(i!=string::npos)//找到了
{
string filetype(path,i+1);//获取出文件类型
logging(DEBUG,"Path: %s | fileType: %s",path.c_str(),filetype.c_str()); // 打印用作debug
return filetype;
}
return ""; //没有后缀
}

需要注意的是,一些 http 请求是这样的

plaintext
1
https://web.musnow.top/about/

后端会收到这样的 path

plaintext
1
/about/

这个 path 里面并没有文件名,而本文前面提到过,如果请求的链接中没有指明文件,那就给客户返回对应路径下的 index.html 文件。如果这个路径下没有 html 文件,则返回 404。这一步在分离请求头中的 path 时已经做了

cpp
1
2
3
4
// 对path进行判断,如果path是以/结尾的,则在path中追加index.html文件名
if(path[path.size()-1] == '/') {
path += HOME_PAGE; //加上被隐藏的index.html文件名
}

回到正题,在获取到文件后缀后,就可以在 map 里面查找对应的 content-type

cpp
1
2
3
4
5
6
7
8
9
10
// 追加正确的文件类型Content-Type
response += "Content-Type: ";
string contentType = "text/plain";
auto it = fileTypeMap.find(fileType);
if(it != fileTypeMap.end()){
contentType = (*it).second;
}

response += contentType;
response += "\r\n";

但是,这样还是出现了问题,图片没办法正常加载

image-20230325212535554

F12 打开开发者面板,可以看到服务器返回的响应头是正确的,但是依旧无法显示出图片

image-20230325212554345

这是因为读取图片和读取 html 文件的操作是不一样。在读取 html 文件的时候,采用的是按行读取的策略

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 读取txt文件
string readTxtFile(const string& file_path)
{
// 如果是文件存在但是打开失败了,应该返回50x代表服务器处理错误
// tcp是面向字节流的,文件需要用二进制打开
ifstream in(file_path, ios::binary);
if(!in.is_open()) //文件打开失败
{
return "503";//文件打开失败
}

// 内容
string content;
string line;
while(getline(in, line))
{
content += line;
}
in.close();
return content;
}

但是图片文件应该需要一个完整的二进制流,而不是按行读取

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 读取图片文件
string readImgFile(const string& file_path)
{
ifstream file(file_path, ios::binary);
// 打开失败,503
if (!file.is_open()) {
return "503";
}

ostringstream ss;
ss << file.rdbuf();
string content = ss.str();
file.close();

return content;
}

为了区别图片和 html 文件,我新增了一个用于判断文件后缀的函数

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const string imageType[] = {"png","jpg","gif","jpeg"}; // 图片类型
// 判断请求头中文件类型是否为图片
bool isImg(const string& fileType)
{
for(auto& t: imageType)
{
// 如果完全一致
if(fileType == t)
{
return true;
}
}
return false;
}

最终合并成同一个函数

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
string readFile(const string &file_path,bool is_img = false)
{
//其实这里应该分两种情况,一种是文件不存在,一种是文件打开失败了
//如果是文件不存在,应该返回404
if(access(file_path.c_str(),0)!=0)//判断文件是否存在,存在返回0
{//windows下相同作用的接口为_access,头文件io.h
return "404";//文件不存在
}
// 读取对应的文件
if(!is_img){
return readTxtFile(file_path);
}
else{
return readImgFile(file_path);
}
}

在服务函数里面,也做出了区别

cpp
1
2
3
4
// 获取文件的后缀
string fileType = getFileType(path);
// 打开文件
string content = readFile(resources,isImg(fileType));

再次测试,成功!

image-20230325213107462

主页 html 文件中配置的 log 也正常显示出来了!(和原来的颜色不一样)

image-20230325213144653