1. HTTP协议
1.1 简介
Http协议被称为超文本传输协议,顾名思义,就是传文件的,因为在系统的概念中视频音频等超文本的资源都可以被看作特定路径下的文件。
在编写网络通信代码时,我们可以自己进行协议的定制,但实际有很多优秀的工程师早就已经写出了许多非常成熟的应用层协议,其中最典型的就是HTTP协议。
1.2 认识URL
统一资源定位符(Uniform Resource Lacator),也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
URL的一般组成部分:

协议方案名
通常使用的是HTTP协议或安全协议HTTPS;
常见的应用层协议:
DNS(Domain Name System)协议:域名系统。
FTP(File Transfer Protocol)协议:文件传输协议。
TELNET(Telnet)协议:远程终端协议。
HTTP(Hyper Text Transfer Protocol)协议:超文本传输协议。
HTTPS(Hyper Text Transfer Protocol over SecureSocket Layer)协议:安全数据传输协议。
SMTP(Simple Mail Transfer Protocol)协议:电子邮件传输协议。
POP3(Post Office Protocol - Version 3)协议:邮件读取协议。
SNMP(Simple Network Management Protocol)协议:简单网络管理协议。
TFTP(Trivial File Transfer Protocol)协议:简单文件传输协议。
登录信息
usr:pass表示的是登录认证信息,包括登录用户的用户名和密码。虽然登录认证信息可以在URL中体现出来,但绝大多数URL的这个字段都是被省略的,因为登录信息可以通过其他方案交付给服务器。
服务器地址
www.example.jp表示的是服务器地址,也叫做域名,比如www.alibaba.com,www.qq.com,www.baidu.com。
需要注意的是,我们用IP地址标识公网内的一台主机,但IP地址本身并不适合给用户看。
我们可以通过ping命令,来获取www.baidu.com和www.alibaba.com域名解析后的IP地址:

为什么要存在域名呢?因为域名具有很好的自描述性,如果用户看见这个IP地址,根本不知道是什么意思,但是如果看见的是域名,那么就可以根据域名了解到这个网址的具体信息。
实际我们可以认为域名和IP地址是等价的,在计算机当中使用的时候既可以使用域名,也可以使用IP地址。只不过在URL中,为了让用户更好浏览,采取了域名的形式进行表示。
服务器端口号
80表示的是服务器端口号。HTTP协议和套接字编程一样都是位于应用层的,在进行套接字编程时我们需要给服务器绑定对应的IP和端口,而这里的应用层协议也同样需要有明确的端口号。
比如常用的HTTP协议、HTTPS协议、SSH协议,它们对应的端口分别为80、443、22;这些端口号与对应协议的一一对应的,所以一般情况下,在URL中端口号的可以省略的。
带层次的文件路径
/dir/index.htm
表示的是要访问的资源所在的路径。访问服务器的目的是获取服务器上的某种资源,通过前面的域名和端口已经能够找到对应的服务器进程了,此时要做的就是指明该资源所在的路径。
这里的路径分隔符是/,而不是\,这也就证明了实际很多服务都是部署在Linux上的。
从HTTP的角度,我们所要获取的"资源"都是文件,既然是文件,那就一定在特定的Linux路径下。
整个URL中,有域名,本质就是IP地址,这个IP是全网唯一的;其次有一定特定的文件路径,这个文件路径在目标机器上也是唯一的,这两个唯一放在一起就确定了全网内的一个唯一文件。
1.3 urlencode和urldecode
如果在搜索关键字当中出现了像/?:这样的字符,由于这些字符已经被URL当作特殊意义理解了,因此URL在呈现时会对这些特殊字符进行转义。
这里有一个工具:
利用这个工具,我们可以进行特殊字符的转义;
选中其中的URL编码/解码模式,在输入C/C++后点击编码就能得到编码后的结果。

再点击解码就能得到原来输入的C/C++。

1.4 HTTP协议格式

再谈协议之前,我们先来看一下上面这张图,对整个网络协议栈有一个宏观认识,下三层的任务是解决通信细节问题,而最上层的应用层需要负责对下层传上来的数据进行处理使用,所以应用层也需要协议,HTTP协议是基于请求和响应的应用层服务,作为客户端,你可以向服务器发起request,服务器收到这个request后,会对这个request做数据分析,得出你想要访问什么资源,然后服务器再构建response,完成这一次HTTP的请求。这种基于客户端服务端的工作方式,我们简称为CS或者BS模式。
由于HTTP是基于请求和响应的应用层访问,因此我们必须要知道HTTP对应的请求格式和响应格式,这也是HTTP协议最重要的内容。
1.4.1 HTTP请求协议格式


HTTP请求由以下四部分组成:
- 请求行:[请求方法]+[url]+[http版本]
- 请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
- 空行:遇到空行表示请求报头结束。
- 请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。
其中,前面三部分一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。
如何将HTTP请求的报头与有效载荷进行分离?
请求行和请求报头就是HTTP的报头信息,而这里的请求正文实际就是HTTP的有效载荷。这时大家要注意请求格式中的一个重要部分------空行,这个空行就可以完美地将HTTP报头和有效载荷进行分离。
我们可以将整个HTTP报文看成一个大的"字符串",只不过这个字符串每行的内容都是用 \n 隔开的,当服务器解析报文的时候,遇到两个连续的"\n"就说明报头已经读取完了。
获取浏览器的HTTP请求
HTTP协议底层通常使用的传输层协议是TCP协议,因此我们可以用套接字编写一个TCP服务器,然后启动浏览器访问我们的这个服务器。

这里我们实现一个简单的服务器,不对客户端进行任何应答,只是将HTTP请求打印出来即可;
cpp
#include <iostream>
#include <fstream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
int main()
{
//创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
return 1;
}
//绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8084);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
return 2;
}
//监听
if (listen(listen_sock, 0) < 0){
cerr << "listen error!" << endl;
return 3;
}
//启动服务器
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(true)
{
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
cerr << "accept error!" << endl;
continue;
}
if (fork() == 0){ //爸爸进程
close(listen_sock);
if (fork() > 0){ //爸爸进程
exit(0);
}
//孙子进程
char buffer[1024];
ssize_t n=recv(sock, buffer, sizeof(buffer)-1, 0); //读取HTTP请求
buffer[n]='\0';
cout << "--------------------------http request begin--------------------------" << endl;
cout << buffer << endl;
cout << "---------------------------http request end---------------------------" << endl;
close(sock);
exit(0);
}
//爷爷进程
close(sock);
waitpid(-1, nullptr, 0); //等待爸爸进程
}
return 0;
}
运行服务器程序后,然后用浏览器进行访问(IP和端口进行访问),此时我们的服务器就会收到浏览器发来的HTTP请求,并将收到的HTTP请求进行打印输出。

说明几点问题:
-
大家访问后会发现控制台有多个HTTP请求,这是因为我们的服务器并没有对浏览器的请求作应答,所以浏览器会认为服务器没有收到HTTP请求,故而会多发几次请求。
-
我们在使用浏览器访问服务器的时候,不需要指明HTTP协议,因为浏览器默认就用的是HTTP协议。
-
大家注意一下请求报头中第一行中的URL,是一个"\",这表示的是web根目录,这个web根目录可以是你的机器上的任何一个目录,这个是可以自己指定的,不一定就是Linux的根目录。
请求行当中的url表示你要访问这个服务器上的哪一路径下的资源。
如果浏览器在访问我们的服务器时指明要访问的资源路径,那么此时浏览器发起的HTTP请求当中的url也会跟着变成该路径。


而请求报头当中全部都是以key: value
形式按行陈列的各种请求属性,请求属性陈列完后紧接着的就是一个空行,空行后的就是本次HTTP请求的请求正文,此时请求正文为空字符串,因此这里有两个空行。
1.4.2 HTTP响应协议格式
HTTP响应协议格式如下:


HTTP响应由以下四部分组成:
-
状态行:[http版本]+[状态码]+[状态码描述]
-
响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
-
空行:遇到空行表示响应报头结束。
-
响应正文:响应正文允许为空字符串,如果响应正文存在,则响应报头中会有一个Content-Length属性来标识响应正文的长度。比如服务器返回了一个html页面,那么这个html页面的内容就是在响应正文当中的。
如何将HTTP响应的报头与有效载荷进行分离?
与HTTP请求一样,HTTP响应当中也是用空行来分离报头和有效载荷的。
构建HTTP响应给浏览器
这里我们可以给浏览器返回一个固定的HTTP响应,我们就将当前服务程序所在的路径作为我们的web根目录,我们可以在该目录下创建一个html文件,然后编写一个简单的html作为当前服务器的首页。
html
<!DOCTYPE html>
<!-- 声明文档类型为HTML5 -->
<html lang="zh-CN">
<head>
<!-- 页面元信息:字符编码 + 页面标题 -->
<meta charset="UTF-8">
<title>欢迎页面</title>
<style>
/* 简单样式:让文字居中显示,提升视觉效果 */
body {
margin: 0;
padding: 0;
height: 100vh; /* 让body占满整个浏览器窗口高度 */
display: flex; /* 弹性布局:实现文字垂直+水平居中 */
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
font-family: "Microsoft YaHei", Arial, sans-serif; /* 适配中文的字体 */
font-size: 24px; /* 字体大小 */
color: #333; /* 字体颜色(深灰色,不刺眼) */
background-color: #f5f5f5; /* 浅灰色背景,区分文字与背景 */
}
</style>
</head>
<body>
<!-- 核心内容:你好,欢迎访问 -->
<div>你好,欢迎访问</div>
</body>
</html>
cpp
#include <iostream>
#include <fstream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
int main()
{
// 创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
cerr << "socket error!" << endl;
return 1;
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8084);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
cerr << "bind error!" << endl;
return 2;
}
// 监听
if (listen(listen_sock, 0) < 0)
{
cerr << "listen error!" << endl;
return 3;
}
// 启动服务器
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while (true)
{
int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
cerr << "accept error!" << endl;
continue;
}
if (fork() == 0)
{ // 爸爸进程
close(listen_sock);
if (fork() > 0)
{ // 爸爸进程
exit(0);
}
// 孙子进程
char buffer[1024];
ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0); // 读取HTTP请求
buffer[n] = '\0';
cout << "--------------------------http request begin--------------------------" << endl;
cout << buffer << endl;
cout << "---------------------------http request end---------------------------" << endl;
#define PAGE "index.html" // 网站首页
// 读取index.html文件
ifstream in(PAGE);
if (in.is_open())
{
in.seekg(0, in.end);
int len = in.tellg();
in.seekg(0, in.beg);
char *file = new char[len];
in.read(file, len);
in.close();
// 构建HTTP响应
string status_line = "http/1.1 200 OK\n"; // 状态行
string response_header = "Content-Length: " + to_string(len) + "\n"; // 响应报头
string blank = "\n"; // 空行
string response_text = file; // 响应正文
string response = status_line + response_header + blank + response_text; // 响应报文
// 响应HTTP请求
send(sock, response.c_str(), response.size(), 0);
delete[] file;
}
close(sock);
exit(0);
}
// 爷爷进程
close(sock);
waitpid(-1, nullptr, 0); // 等待爸爸进程
}
return 0;
}
因此当浏览器访问我们的服务器时,服务器会将这个index.html文件响应给浏览器,而该html文件被浏览器解释后就会显示出相应的内容。

此外,我们也可以通过telnet
命令来访问我们的服务器,此时也是能够得到这个HTTP响应的。

说明几点:
-
我们在进行网络请求时,如果我们不指明资源路径,那么默认访问的就是web根目录下的index.html文件。
-
上面的代码我们只是作为示例,在构建HTTP响应时,在响应报头当中只添加了一个属性信息Content-Length表示响应正文的长度,实际HTTP响应报头当中的属性信息还有很多。
HTTP为什么要交互版本?
-
HTTP请求中的请求行和应答中的状态行都包含了HTTP版本信息,HTTP请求是由客户端发的,那么其保存的就是客户端的HTTP版本,同理,HTTP应答是服务器发的,保存的就是服务器的HTTP版本。
-
交互双方http版本,主要还是为了兼容性的问题。
1.5 HTTP方法

上面这么多方法中,最常用的就两个------GET、POST,我们这里也主要讨论这两种。
GET方法和POST方法
我们上网其实本质上就两种动作,要么我们从远端服务器获取数据与资源;要么我们将数据提交给远端服务器。
GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器。
当然GET方法也可以将数据上传给服务器,只不过传参方式与POST方法有区别。
- GET方法是通过url传参的。
- POST方法是通过正文传参的。
POST方法能传递更多的参数,因为url的长度是有限制的,POST方法通过正文传参就可以携带更多的数据。
某种意义上来说,POST方法相较于GET方法安全一些,但实际上两种方法都不是100%安全的,只是说POST方法将参数信息放在正文中,不像GET方法直接放在URL中进行回显那么明显。
1.6 HTTP状态码

最常见的状态码,比如200(OK),404(Not Found),403(Forbidden请求权限不够),302(Redirect),504(Bad Gateway)。
重定向状态码
-
重定向就是通过各种方法将各种网络请求重新定个方向转到其它位置,此时这个服务器相当于提供了一个引路的服务。
-
重定向又分为临时重定向(302、307)和永久重定向(301).
-
如果某个网站是永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。
-
如果某个网站是临时重定向,那么每次访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。
下面我们来简单演示一下重定向;
进行临时重定向时需要用到Location字段,Location字段是HTTP报头当中的一个属性信息,该字段表明了你所要重定向到的目标网站。
我们这里要演示临时重定向,可以将HTTP响应当中的状态码改为307,然后跟上对应的状态码描述,此外,还需要在HTTP响应报头当中添加Location字段,这个Location后面跟的就是你需要重定向到的网页,比如我们这里将其设置为腾讯的首页。
html
#include <iostream>
#include <fstream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
int main()
{
// 创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
cerr << "socket error!" << endl;
return 1;
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8084);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
cerr << "bind error!" << endl;
return 2;
}
// 监听
if (listen(listen_sock, 0) < 0)
{
cerr << "listen error!" << endl;
return 3;
}
// 启动服务器
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while (true)
{
int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
cerr << "accept error!" << endl;
continue;
}
if (fork() == 0)
{ // 爸爸进程
close(listen_sock);
if (fork() > 0)
{ // 爸爸进程
exit(0);
}
// 孙子进程
char buffer[1024];
ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0); // 读取HTTP请求
buffer[n] = '\0';
cout << "--------------------------http request begin--------------------------" << endl;
cout << buffer << endl;
cout << "---------------------------http request end---------------------------" << endl;
// 构建HTTP响应
string status_line = "http/1.1 307 Temporary Redirect\n"; // 状态行
string response_header = "Location: https://www.qq.com/\n"; // 响应报头
string blank = "\n"; // 空行
string response = status_line + response_header; // 响应报文
// 响应HTTP请求
send(sock, response.c_str(), response.size(), 0);
}
close(sock);
exit(0);
// 爷爷进程
close(sock);
waitpid(-1, nullptr, 0); // 等待爸爸进程
}
return 0;
}
此时运行我们的服务器,当我们用telnet
命令登录我们的服务器时,向服务器发起HTTP请求时,此时服务器给我们的响应就是状态码307,响应报头当中是Location字段对应的就是腾讯首页的网址。

telnet命令实际上只是一来一回,如果我们用浏览器访问我们的服务器,当浏览器收到这个HTTP响应后,还会对这个HTTP响应进行分析,当浏览器识别到状态码是307后就会提取出Location后面的网址,然后继续自动对该网站继续发起请求,此时就完成了页面跳转这样的功能,这样就完成了重定向功能。
1.7 HTTP常见的Header
Content-Type:数据类型(text/html等)。
Content-Length:正文的长度。
Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
User-Agent:声明用户的操作系统和浏览器的版本信息。
Referer:当前页面是哪个页面跳转过来的。
Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
Host
Host字段表明了客户端要访问的服务的IP和端口。
当我们用浏览器访问服务器时,其中请求字段中的HOST填的就是我们服务器的IP和端口。
但是在这里就会产生一个奇怪的问题,客户端本来就是要访问服务器的,为啥还要标识服务器的IP和端口?
这是因为有一些服务器是代理服务器,就是代替客户端向其他服务器发起网络请求,然后将请求得到的结果再返回给客户端;在这种情况下,客户端就必须告诉代理服务器它要访问的服务器的IP和端口,此时Host字段就有用了。
User-Agent
User-Agent代表的是客户端对应的操作系统和浏览器的版本信息。
这个字段的作用就在于当我们向目标网站发起请求时,该字段包含了我们的主机信息,此时网站就会想你推送相匹配的软件版本。
Referer
Referer代表的是你当前是从哪一个页面跳转过来的。Referer记录上一个页面的好处一方面是方便回退,另一方面可以知道我们当前页面与上一个页面之间的相关性。
Keep-Alive(长连接)
长连接就是建立连接后,客户端可以不断的向服务器一次写入多个HTTP请求,而服务器在上层依次读取这些请求就行了,此时一条连接就可以传送大量的请求和响应,这就是长连接。
如果HTTP请求或响应报头当中的Connection字段对应的值是Keep-Alive,就代表支持长连接。
1.8 Cookie和Session
HTTP实际上是一种无状态协议,HTTP的每次请求/响应之间是没有任何关系的,但你在使用浏览器的时候发现并不是这样的。
比如我们登录一次B站首页后,我们就算把电脑关机或者重启,第二次登录我们的账号密码依然还在,不需要我们进行二次输入。,这实际上是通过cookie技术实现的。
cookie是什么呢?
因为HTTP是一种无状态协议,如果没有cookie的存在,那么每当我们要进行页面请求时都需要重新输入账号和密码进行认证,这样会很麻烦。

当我们第一次登录某个网站时,需要输入我们的账号和密码进行身份认证,此时如果服务器经过数据比对后判定你是一个合法的用户,那么为了让你后续在进行某些网页请求时不用重新输入账号和密码,此时服务器就会进行Set-Cookie的设置。(Set-Cookie也是HTTP报头当中的一种属性信息)。
当认证通过并在服务端进行Set-Cookie设置后,服务器在对浏览器进行HTTP响应时就会将这个Set-Cookie响应给浏览器。而浏览器收到响应后会自动提取出Set-Cookie的值,将其保存在浏览器的cookie文件当中,此时就相当于我的账号和密码信息保存在本地浏览器的cookie文件当中。
第一次登录认证后,浏览器再向服务器发起请求时,就会自动包含cookie字段,其中携带的就是第一次的登录信息,此后对端服务器需要对你进行认证时就会直接提取出HTTP请求当中的cookie字段,而不会重新让你输入账号和密码了。
内存级别vs文件级别
cookie文件可以分为两种,一种是内存级别的cookie文件,另一种是文件级别的cookie文件。
它们的区别很简单,如果你关闭网站,然后再次访问,浏览器还是需要你输入登录信息,那么证明浏览器中保存的cookie信息是内存级别的。反之,如果不需要你输入登录信息,那么证明是文件级别的。