文章目录
📖 前言
从本章开始,我们要自顶向下的学习网络协议,有了前面的基础,我们再学习http协议就更容易理解了。本章的目标是学习http协议,熟悉http的协议格式,方法,状态码等概念,并且对http常见的报头进行学习。目标已经确定,准备开讲啦… 👉 【网络层状结构复习】
1. 认识URL && 引入http协议
平时我们俗称的 "网址" 其实就是说的 URL。
URL是(Uniform Resource Locator)的缩写,意为统一资源定位符:
我们上一章节学习的 序列化与反序列化 所做的工作是在那一层呢?—— 应用层!
我们再来复盘一下之前的网络编程的过程:
- 基本系统socket接口的使用
- 指定协议
- 编写业务
那么有没有已经非常成熟的场景,自定义的协议,这个协议写的不错,然后成了应用层特定协议的标准被别人直接使用呢?有的!!
比如说:http、https、smpt、ftp、DNS…
在解析完协议之后需要被反序列化,变成结构化数据。之后才形成完整的请求,最后才能交给业务被计算。
使用确定协议的时候,一般显示的时会缺省端口号:
- 缺省的意思是在URL上,或者分享的链接上,不用带端口号。
- 并不代表发送请求的时候是没有的,它是有的!而且是必须得有!
- 任何一个浏览器或者手机app,收到链接点开的时候,浏览器或者app必须自动添加上端口号!
- 只不过没有在URL中体现出来。
所以,浏览器访问指定的URL的时候,浏览器必须给我们自动添加port。
浏览器如何得知,URL匹配的port是谁呢?特定的众所周知服务,端口号必须是确定的!!
例如:httpserver->80
、httpsServer->443
、ssh->22
。
服务和端口是必须绑定的,用户自己写的网络服务bind端口的时候,只能绑定1024之后的端口[1024 ~ n]
。
域名和DNS:
- 域名是指互联网上某个服务器的地址名称,用于标识和定位一个互联网上的资源。
- 域名通常由多个部分组成,以点号(.)分隔,例如:
baidu.com
。 - 域名系统 (DNS) 可以将域名转换为IP地址以便计算机能够访问该服务器上的资源。
- 使用域名比直接使用IP地址更方便易记,因此被广泛应用于互联网上的各种服务。
浏览器加一个服务,DNS服务自动帮我们做的域名解析。域名必须被转化成为IP访问网络服务,服务端必须具有port。网络通信的本质:
socket
。(ip + port
)
URL编码问题:
urlencode
和urldecode
是用于对URL中的特殊字符进行编码和解码的函数。urlencode
函数将URL中的特殊字符转换为%后面跟着两位十六进制数的形式。urldecode
函数则进行相反的操作,它将编码过的URL字符串解码回原始的字符串形式。
发起http请求的时候,有些字段时需要编码的,注意:编码并不等于加密。一个%后面跟两个16进制数,代表的就是一个字符,汉字是三个字符代表一个汉字的,所以两个汉字是6个百分号。
信息在本地编码之后,交给服务器端,服务器再对编码进行解码。所以就需要encode和decode。编码是浏览器做的,而解码是服务端做的。
http是做什么的?
http超文本传输协议,为什么叫超文本的,因为网页是文本的,超在哪呢?超在可以传图片,视频音频。
查阅文档,看音视频都是以网页的形式呈现的,http获取网页资源的视频,音频等也都是文件。
- http是向特定的服务器申请特定的“资源"的,获取到本地,进行展示或者某种使用的。
- 如果我们客户端没有获取的时候,资源在哪里呢?
-
- 就在的网路服务器(软件)所在的服务器,硬件,计算机上。
- 服务器都是什么系统呢?
-
- Linux!
- 这些资源都是文件,都在Linux服务器上,要打开资源文件、读取、发送会给客户端的前提:
-
- 软件服务器,必先找到这个文件!
- Linux要如何找到文件呢?
-
- 通过路径!
- 网络视角下,Linux服务器上的所有的东西,我们都称之为资源。
- 无论是图片视频音频还是网页,都叫做资源。
- http就是从本地向服务端获取资源的,这就是http协议。
举个栗子:
抓到了百度的网页,这个网页,就是通过百度对应的服务器,通过一些客户端,可以是浏览器或者是一些工具,把它的网页拿下来了,就是文件。
为什么URL可以定位唯一的资源:
- 索要访问的某种资源,一定是在全球特定的主机上的,以特定的服务的方式,在特定的路径下保存着的。
- 因为ip地址定位到了全球当中唯一的主机,端口号定位了主机上唯一的服务,所以
ip + port
定位了互联网中唯一的服务。 - 路径一定是在该主机上的,所以路径本身就具备唯一性。
- 所以就能使用URL定位在全球当中的任意一个资源。
2. http协议格式
2.1 宏观格式:
这么多行,主体是很多http请求的报头属性,请求报头的报头属性非常多,所有的字段都是Key:空格value
。
http是一个基于行的一个协议:
- 报头最后一行是一个空行,能够保证,前面的内容不会是空行,而且必须有字段。
- 按照http协议在读取的时候,可以按行去读,直到读到空行,就能证明将报头和请求行读完了。
- 相当于用一个空行来作为一个分隔符,让自己的报头能够全被解析出去。
- 任何一个协议都要解决一个问题, 将自己的有效载荷和自己的报头分离的问题。
任何协议的request or response
都是:报头 + 有效载荷。
- http如何保证自己的报头和有效载荷被全部读取呢?(无论是请求还是响应)
-
- 读取完整报头:按行读取,直到读取到空行!
- 你又如何保证,你能读取到完整的正文呢?
-
- 报头能读取完毕,请求或者响应属性中,一定要包含正文的长度!
当对http有了宏观结构上的认识之后,也有一些不规范的写法,http请求当中可能会少一些报头,可能会没有正文,但是都必须有请求行和空行。空行存在的最大意义就是将报头和有效载荷分离。
ip具有唯一性,端口在该服务器上也具有唯一性,那么路径就更具有唯一性了。诸多唯一性构建起来就构成了一个URL。
2.2 实验演示:
有了之前的套接字编程经验,我们直接写一个简易版的服务端(TCP套接字):
class ServerTcp
{
public:
ServerTcp(uint16_t port, const std::string &ip = "")
: port_(port),
ip_(ip),
listenSock_(-1)
{
quit_ = false;
}
~ServerTcp()
{
if (listenSock_ >= 0)
close(listenSock_);
}
public:
void init()
{
// 1. 创建socket
listenSock_ = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock_ < 0)
{
exit(1);
}
// 2. bind绑定
// 2.1 填充服务器信息
struct sockaddr_in local; // 用户栈
memset(&local, 0, sizeof local);
local.sin_family = PF_INET;
local.sin_port = htons(port_);
ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
// 2.2 本地socket信息,写入sock_对应的内核区域
if (bind(listenSock_, (const struct sockaddr *)&local, sizeof local) < 0)
{
exit(2);
}
// 3. 监听socket,为何要监听呢?tcp是面向连接的!
if (listen(listenSock_, 5 /*后面再说*/) < 0)
{
exit(3);
}
// 运行别人来连接你了
}
void loop()
{
signal(SIGCHLD, SIG_IGN); // only Linux
while (!quit_)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int serviceSock = accept(listenSock_, (struct sockaddr *)&peer, &len);
if (quit_)
break;
if (serviceSock < 0)
{
// 获取链接失败
cerr << "accept error ...." << endl;
continue;
}
// 多进程版本
pid_t id = fork();
assert(id != -1);
if (id == 0)
{
close(listenSock_); // 建议
if (fork() > 0)
exit(0);
// 孙子进程
handlerHttpRequest(serviceSock);
exit(0); // 进入僵尸
}
close(serviceSock);
wait(nullptr);
}
}
private:
// sock
int listenSock_;
// port
uint16_t port_;
// ip
std::string ip_;
// 安全退出
bool quit_;
};
不清楚的小伙伴看过来 👉 TCP套接字复习。
我们用的是多进程版本的服务器。
提前搞一些宏标识:
#define CRLF "\r\n"
#define SPACE " "
#define SPACE_LEN strlen(SPACE)
#define HOME_PAGE "index.html"
#define ROOT_PATH "wwwroot"
服务端响应:
void handlerHttpRequest(int sock)
{
char buffer[1024];
ssize_t s = read(sock, buffer, sizeof buffer);
if (s > 0)
cout << buffer;
std::string path = getPath(buffer);
// path = "/a/b/index.html";
// recource = "./wwwroot"; // 我们的web根目录
// recource += path; // 最终拼出来了--> ./wwwroot/a/b/index.html
// 1. 文件在哪里? 在请求的请求行中,第二个字段就是你要访问的文件
// 2. 如何读取
std::string recource = ROOT_PATH;
recource += path;
cout << "[recoure]: " << recource << std::endl;
std::string html = readFile(recource);
std::size_t pos = recource.rfind(".");
std::string suffix = recource.substr(pos);
cout << "[suffix]: " <<suffix << endl;
// 开始响应
std::string response;
// 两百这个状态码代表这次请求时OK的
response = "HTTP/1.0 200 OK\r\n";
if (suffix == ".jpg")
response += "Content-Type: image/jpeg\r\n"; // Content-type标定了正文的类型是什么
else
response += "Content-Type: text/html\r\n";
response += ("Content-Length: " + std::to_string(html.size()) + "\r\n");
response += "\r\n"; // 空行
response += html; // 正文
send(sock, response.c_str(), response.size(), 0);
}
首先我们读取收到的序列化数据,提取要获取的数据的路径,根据路径在服务器上找对应的文件,然后将文件提取出来。
随后开始响应,填写响应报头,根据文件的格式填好每个报头字段,将提取到的文件拼接到最后,然后发送给客户端。
Content-type
标定了正文的类型是什么,要根据不同的格式来填Content-type
。👉 常用对照表
获取路径函数:
std::string getPath(std::string http_request)
{
std::size_t pos = http_request.find(CRLF);
if (pos == std::string::npos)
return "";
// 请求行
std::string request_line = http_request.substr(0, pos);
// GET /a/b/c http/1.1
std::size_t first = request_line.find(SPACE);
if (first == std::string::npos)
return "";
std::size_t second = request_line.rfind(SPACE);
if (second == std::string::npos)
return "";
std::string path = request_line.substr(first + SPACE_LEN, second - (first + SPACE_LEN));
if (path.size() == 1 && path[0] == '/')
path += HOME_PAGE;
return path;
}
注意:
- 在http请求中默认访问的文件路径是根目录,也称为网站的根目录或文档根目录。
- 但是这个根目录并不是服务器的根目录,难不成将服务器所有内容返回给客户端吗,不现实!
读取文件函数:
std::string readFile(const std::string &recource)
{
std::ifstream in(recource, std::ifstream::binary);
// 检测文件是否成功打开了
if (!in.is_open())
return "404";
std::string content;
std::string line;
while (std::getline(in, line))
content += line;
in.close();
return content;
}
资源文件:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>测试</title>
</head>
<body>
<h3>hello my server!</h3>
<p>我终于测试完了我的代码</p>
<form action="/a/b/c.html" method="post">
Username: <input type="text" name="user"><br>
Password: <input type="password" name="passwd"><br>
<input type="submit" value="Submit">
</form>
</body>
</html>
当然了,我们也不懂前端,随便写一点样例。
主函数:
#include "server.hpp"
static void Usage(std::string proc)
{
std::cerr << "Usage:\n\t" << proc << " port" << std::endl;
std::cerr << "example:\n\t" << proc << " 8080\n"
<< std::endl;
}
// ./ServerTcp local_port local_ip
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = atoi(argv[1]);
ServerTcp svr(port);
svr.init();
svr.loop();
return 0;
}
用浏览器获取响应:
用tenlnet获取响应:
telnet是个命令,可以远程的以协议的方式,去登录某种服务:
一般我们浏览器中请求的服务,是一定有人曾经写过这样的服务,部署在Linux上,然后才可以请求。
3. http的方法
方法 | 描述 |
---|---|
GET | 请求指定的资源。返回响应主体 |
POST | 向指定资源提交数据进行处理请求。通常用于提交表单或上传文件 |
PUT | 从客户端向服务器传送的数据取代指定的文档的内容 |
DELETE | 请求服务器删除指定的资源 |
HEAD | 获取对应的HTTP报头信息,但不返回响应主体 |
OPTIONS | 返回服务器支持的HTTP方法列表 |
PATCH | 对资源进行部分修改 |
http的请求方法除了GET和POST还有其他方法,但是用的特别少,而且在大部分的服务器当中,基本上很多的方法被注释掉了,被禁掉了。
input标签,会在网页当中给我们构建一个输入框。
URL可以在?
后面带参数,参数是KV的,K对应的就是表单当中的name,V就是往表单当中输入的东西。
我们的网络行为无非有两种:
- 我想把远端的资源拿到你的本地:
GET /index.html http/1.1
。 - 我们想把本地属性字段,提交到远端:
GET or POST
。
3.1 GET方法:
在HTTP中GET会以明文方式将我们对应的参数信息,拼接到URL中,以这样的方式完成了提参的过程。
HTTP GET方法通常不包含请求正文。HTTP GET方法用于从服务器请求资源,通常通过在URL中指定参数来传递请求参数,而不是在请求正文中传递参数。GET方法将请求参数附加到URL的末尾,形成查询字符串,并将其发送到服务器。
3.2 POST方法:
POST方法提交参数,会将参数以明文的方式,拼接到http的正文中来进行提交!
POST方法,请求正文,一般携带的是http请求所带的参数。这个参数类似于C语言C++的字符串。
在浏览器中提交表单的时候,表单的内容以KV的方式拼接到正文了。
3.3 GET vs POST:
小结:
- GET通过URL传参
- POST通过正文传参
- GET方法传参不私密
- POST方法因为通过正文传参,所以相对比较私密一些
- GET通过URL传参,POST通过正文传参,所以一般一些比较大的内容都是通过POST方式传参的
http传输数据能有功能上的满足,但是数据却在网络当中裸奔。所以,这个数据无论如何都是不安全的。
如何选择:
- URL没有类型字段,而是在请求和响应报文的头部中使用
Content-Type
字段来指定数据类型。 - 所以就决定了,正文部分可以传递更丰富的字段类型。
- URL要是传一些二进制内容,而且量太大了,挺不合适的。
- 所以如果我们平时传一些文件,对私密性有要求,并且体积比较大的,我们一般就用POST。
比如网页端上传一些简历,上传一些视频,或者是上传百度云盘的资料,我们都用的是POST来传,因为POST有详细的字段类型说明。
4. HTTP的报头和状态码
http常见的Header:
Header | 描述 |
---|---|
Content-Type | 数据类型,指示服务器返回的正文部分的数据类型 |
Content-Length | 有效载荷的长度 |
Host | 客户端告知服务器所请求的资源在哪个主机的哪个端口 |
User-Agent | 声明用户的操作系统和浏览器版本信息 |
Referer | 当前页面是从哪个页面跳转过来的 |
Location | 搭配3xx状态码使用,告诉客户端接下来要去哪里访问 |
Cookie | 在客户端存储少量信息,通常用于实现会话功能 |
Linux网页的跳转本质是Linux目录的跳转,referer代表上一个目录是什么。
http这样的协议几乎是基于纯文本的,而且可展性还非常强,如果未来http想要新功能,新属性,直接在请求和响应报头里添加KV字符串行就可以。
状态码:
状态码范围 | 类别 | 描述 |
---|---|---|
1XX | 信息性状态码 | 服务器已接受请求,需要客户端继续操作 |
2XX | 成功状态码 | 请求已成功处理 |
3XX | 重定向状态码 | 需要完成进一步的操作以完成请求 |
4XX | 客户端错误状态码 | 请求包含语法错误或无法完成请求 |
5XX | 服务器错误状态码 | 服务器在处理请求时发生内部错误或超时等 |
目前被主流浏览器所接受的重定向是301和302。
301和302通常是做什么的呢?
- 301:永久重定向
- 302: 临时重定向
临时重定向:
- 假设要访问某个网站,但是这个网站局部性的有内容要升级,并不想让用户访问这个服务。
- 当有用户来访问时,我们就将其重定向到备份的服务当中,当内容升级完了,再将临时重定向去掉。
- 客户端再来访问时,就可以访问到了。
永久重定向:
- 老网站的用户数量非常的多但是想要用户去访问新的网站。
- 所以将新网站推出,让别人能够访问,其次老网站不关。
- 将所有的服务全部下线,并且只提供一个功能,在用户请求它时重定向就可以了。
重定向代码:
void handlerHttpRequest(int sock)
{
char buffer[1024];
ssize_t s = read(sock, buffer, sizeof buffer);
if (s > 0)
cout << buffer;
std::string response = "HTTP/1.1 302 Temporarily Moved\r\n";
// std::string response = "HTTP/1.1 301 Permanently Moved\r\n";
// Location后面填的一定是个网址
response += "Location: https://www.qq.com/\r\n";
response += "\r\n";
send(sock, response.c_str(), response.size(), 0);
}
在响应时状态码填的是301或者302,代表的意思是重定向,给客户端响应,还会给一个新的网址。
因为我们是服务端将从客户端收到的报文打印出来,所以当客户端请求时,会将请求报文打印出来,然后再将带有location的报文发给客户端。
但是我们是打印不出来带有location字段的报文的,因为客户端重定向到了别的服务器了。
浏览器演示图:
telnet演示图:
通过Telnet可以获取远端服务器的响应。当你使用Telnet连接到远程服务器后,你可以输入命令并发送给服务器,然后等待服务器的响应。服务器会返回相应的结果,包括命令执行的输出、状态信息等。
小结:
- 永久和临时的区别,它俩的应用场景是不一样的,对客户端的影响也是不一样的。
- 也有其他的重定向例如307,因为其他重定向对应的浏览器的支持并不好,不一定被所有的浏览器所接受。
- 但是301和302是被所有浏览器接受的。
5. http的cookie
5.1 http协议的无状态:
http协议特点之一:无状态
是什么意思呢?
—— 用户的各种资源请求行为,http本身并不做任何记录!!
HTTP的无状态(Stateless)指的是:
- 每个HTTP请求都是独立的,服务器不会保存与之前请求相关的任何信息。
- 每个请求都包含足够的信息以便服务器能够理解和处理该请求,而无需依赖于之前的请求。
但是用户需要会话保持!!
HTTP的Cookie(也称为HTTP Cookie、Web Cookie或浏览器Cookie)
是一种用于网站与浏览器之间进行状态管理的技术。它通过在用户浏览器中存储少量数据,并在以后的请求中发送给同一网站,实现对用户状态的追踪和识别。
- 当用户第一次访问一个网站时,网站服务器可以通过HTTP响应报头中的
Set-Cookie
字段将一个Cookie
发送给用户。 - 浏览器会将这个
Cookie
存储起来,并在用户以后对同一网站发送请求时,通过HTTP请求头中的Cookie
字段将这个Cookie
发送回服务器。
编写响应报文:
获取服务器的响应:
第二次以后的请求会携带一个字段叫做cookie
,就代表我们曾经写的内容:
- 当写入一个cookie之后,在进行后续的访问时,浏览器请求,都会帮我们携带曾经写入到浏览器对应的cookie信息。
- cookie文件内容,所以后端认证时,就不需要再输入用户名和密码了,就可以始终以登录状态,来进行访问。
- 通过使用Cookie,服务端可以保持跟踪客户端的会话信息、用户认证状态等。同时,客户端也可以根据需要修改和删除Cookie值。
cookie
就是浏览器帮我们维护的一个文件:
- 浏览器维护的文件,存在磁盘或者内存级。
- 只要不关闭这个浏览器,cookie文件依旧在,但是如果把浏览器结束掉,那么cookie文件就没了。
- 不同的浏览器和不同的设备(如电脑、手机等)可能具有不同的Cookie策略和存储方式。
在浏览器中查看Cookie:
5.2 cookie的安全问题:
当第一次客户端请求服务端的时候,将用户账号和密码发给服务端,服务端完成匹配之后,将携带用户账号和密码的Cookie文件返回给客户端,但是这是极其不安全的!
一旦有中间人在服务端返回给客户端的时候,将返回内容截取,那么用户的账号和密码就会泄露。
现在主流的是:cookie + session
服务端在收到客户端请求时,在内部验证信息,一旦验证通过,不再给客户端返回携带用户账号和密码的Cookie文件,而是在服务端本地形成一个session文件!
- 用户的临时私密信息,保存在自动形成的session文件中。
-
- session_id是具有唯一性的,是一个用于标识和跟踪用户会话状态的唯一标识符。
- 用户的所有信息,此时就不在客户端cookie维护了,而是在服务端将用户所有私有信息维护起来。
- 实际上,用户的个人信息通常存储在服务器端的一个数据结构(如内存、数据库)中。
-
- 服务器会根据Session ID来查找相应的会话数据,从而获取用户的个人信息。
session里包含了文件的私密信息,包括了这个用户是谁,浏览痕迹是什么,最近一次访问时间是什么时候session有没有过期等等。只要拿着session_id找到session文件,就证明用户处于登录状态,然后服务器就允许该用户去访问指定资源。
依旧存在安全问题:
- 从此往后,客户再去访问时,都会携带自己的session_id,从而来保持这个客户处于在线状态。
- 但是还是没有彻底解决相应问题,这样只解决了私密信息不会泄露了。
- 因为用户名和密码在服务端,黑客只能拿到session_id,对于用户来讲,账号至少是安全的。
6. http1.1长连接
用户所看到的完整的网页内容,背后可能是无数次http清求!http底层主流采用的就是tcp协议!
长连接(Connection: keep-alive):
- 一次http请求,获取一张给用户呈现的完整网页时,http建立一 个连接。
- 此时服务端和客户端双方,都要进行http版本协商,所以这二者都要携带上自己http协议的版本。
- 请求时是
http1.1
,响应时也是http1.1
,同时还携带了一个Connection
字段,叫做keep-alive
。 - 双方如果都是
http1.1
,并且字段都携带keep-alive
,意味着我们在进行底层连接协商的时候,双方都同意采用长连接方案。
http1.0当中是基于短连接的,短连接一次只处理一个http请求!Connection: closed
对于长连接的理解:
一张网页有若干个元素构成,我们在底层发起请求,这个连接暂时不断开,发起若干次http请求,同时把若干次http请求全部都发送到服务器,服务器从一个连接里读到的,不仅仅是一个请求,它可以读到很多请求,然后按照顺序,再把若干个响应全部都返回给客户端,当客户端拿到了完整的网页时,这个连接才断开。
也就是说一次获取若干个元素时,这些若干个元素用一条连接全部请求和响应,不用重复的建立TCP底层的三次握手,四次挥手。这样就能大大的提高效率。
其中双方支持长连接协商的时候,Connection
如果是keep-alive
,客户端发的请求里带了,服务端的响应里带了,就说明双发协商成功,我们双方都认可采用keep-alive
,也叫做连接保活的策略。
因为http1.1有了keep-alive
,为了区分http1.0,所以原来的Connection如果是close,就叫做只支持短连接。
如果http请求每次都采用短连接的话:
- 最大的成本就是在底层要不断地进行三次握手和四次挥手。
- 这就大大降低了获取的速度,所以短连接就不再符合时宜。
怎么请求的就怎么晌应(按照顺序响应):pipeline技术。
一个连接有多个请求了,如何保证服务端读取请求时,都是一个完整的请求呢?
- 根据报文中的
Content-length
来读取。 - 能保证读到的是一个完整的请求,也能保证不会读到别的请求。
- 经过报头和有效载荷合理分离,就能做到将报文和报文之间做到完整分离。
- 那么多个http请求能够向服务端同时发起多个请求。
http是基于tcp的,tcp协议是面向连接的,但是http是无连接的,怎么解释?(重点)
- http协议和tcp协议是两层协议,是毫无相关的,tcp的面向连接和http无关。
- http是无连接的,只是用了一下tcp的能力,把信道建立好,用tcp来完成http的工作,比如说发起http请求。
- 但是http在通信时,并不会建立连接。
所以,http最核心的特点是超文本传输协议,是一个无连接,无状态的一个应用层协议,这就是http的定义。