本篇博客整理了 TCP/IP 分层模型中应用层的 HTTP 协议和 HTTPS协议,旨在让读者更加深入理解网络协议栈的设计和网络编程。
目录
一、协议是什么
1)结构化数据的传输
2)序列化和反序列化
补)网络版计算器
.1- 协议定制
.2- 服务端
.3- 客户端
.4- 测试效果
二、HTTP 协议
1)网址 URL
.1- 协议方案名
.2- 登录信息
.3- 服务器地址
.4- 服务器端口号
.5- 带层次的文件路径
.6- 查询字符串
.7- 片段标识符
2)urlencode 和 urldecode
3)HTTP 协议格式
.1- 请求协议格式
.补- 模拟获取浏览器的 HTTP 请求
.2- 响应协议格式
.补- 模拟构建HTTP并响应给浏览器
4)常用方法
5)状态码
6)常见报头
7)Cookie 和 Session
.补- 实验演示 Set-Cookie 字段
三、HTTPS 协议
1)HTTPS 与 HTTP
2)对称加密与非对称加密
一、协议是什么
协议是网络协议的简称。网络协议是通信计算机双方必须共同遵从的一组约定,内容包括怎么建立连接、怎么互相识别等。
为了使数据在网络上能够从源到达目的,网络通信的参与方必须遵循相同的规则,而这套规则被称为协议(protocol),最终都需要通过计算机语言的方式表现出来。只有通信计算机双方都遵守相同的协议,计算机之间才能互相通信交流。
简单来说,协议主要应用在数据的传输过程中,是需要通过数据来通信的双方共同遵守的一种约定。
1)结构化数据的传输
通信双方在进行网络通信时,如果需要传输的数据是一个字符串,只需直接将这个字符串发送到网络中,对端就能从网络中获取;如果需要传输的是一些结构化的数据,就不能将这些数据一个个发送到网络当中。
将这些结构化的数据单独一个个的发送到网络当中,那么对端也只能从网络中一个个获取,此后,对端还得弄清楚如何将数据组合还原,这就使数据的传输过程十分复杂。因此,最好把这些结构化的数据打包,统一发送到网络中,这样一来,对端每次从网络中获取到的,就是一份完整的数据。而这种结构化数据的传输情景,就经常发生于客户端向服务端发送数据请求。
【Tips】网络通信中,常见的数据“打包”方式
- 将结构化的数据组合成一个字符串
例如,客户端可以按某种方式,将这些结构化的数据组合成一个字符串,然后将这个字符串发送到网络中,这样一来,服务端每次从网络中获取到字符串后,再以相同的方式对这个字符串进行解析,就能提取出这些结构化的数据。
- 定制结构体 + 序列化和反序列化
例如,客户端可以定制一个结构体,将数据请求的相关信息都封装在这个结构体中。客户端发送数据时先对数据进行序列化,将其转换成网络标准数据格式并发送到网络中,服务端接收到数据后再对其进行反序列化,按照与序列化相同的规则把接收到的数据转化为结构体,然后从结构体中提取出数据请求的相关信息。
2)序列化和反序列化
数据的传输离不开网络协议栈,而网络协议栈是分层的。
在 OSI 参考模型中,表示层的作用就是,实现设备固有数据格式和网络标准数据格式的转换。其中,设备固有的数据格式指的是数据在应用层上的格式,而网络标准数据格式则指的是序列化之后可以进行网络传输的数据格式。
【Tips】序列化和反序列化的目的
- 序列化:将对象的状态信息转换为可以存储或传输的形式(字节序列)。
在网络传输时,序列化目的是为了方便网络数据的发送和接收,无论是何种类型的数据,在网络数据传输的底层统一是二进制序列的。
- 反序列化:把字节序列恢复为对象。
经序列化后的二进制序列数据,只能在网络传输的底层被识别,而无法在上层应用被识别,因此就需要进行反序列化,将从网络中获取的二进制序列数据,转换成应用层能够识别的数据格式。
可以认为,网络通信和业务处理是处于不同层级的。
在进行网络通信时,底层能看到的都是二进制序列的数据,而在进行业务处理时,上层能看得到则是可以被上层识别的数据。如果数据需要在业务处理和网络通信之间进行传输,就需要对数据进行对应的序列化或反序列化操作。
补)网络版计算器
.1- 协议定制
要实现一个既有服务端又有客户端的网络版计算器,首先得保证通信双方遵守了某种协议。
服务端和客户端发送和接收的数据,可以分为请求数据和响应数据,因此就需要分别对请求数据和响应数据进行约定,此处用结构体来实现,分别实现一个请求结构体和一个响应结构体。
请求结构体是针对于客户端的,客户端是负责向服务端发送计算式问题的,因此请求结构体中要包括两个操作数和对应的运算操作、运算符。
响应结构体是针对于服务端的,服务端是负责向客户端响应计算结果的,因此响应结构体中要包括一个计算结果。此外,还要包括一个状态字段,以表示本次计算是否有意义,并规定状态字段的含义如下:
- 状态字段为 0,表示计算成功。
- 状态字段为 1,表示出现除 0 错误。
- 状态字段为 2,表示出现模 0 错误。
- 状态字段为 3,表示非法计算。
协议定制好后,必须要被客户端和服务端同时看到,这样它们才能遵守这个约定,因此将请求结构体和响应结构体写到一个头文件中,并让客户端和服务端都应该包含这个头文件。
- protocol.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
//请求
typedef struct request{
int x; //左操作数
int y; //右操作数
char op; //操作符
}request_t;
//响应
typedef struct response{
int code; //计算状态
int result; //计算结果
}response_t;
.2- 服务端
客户端和服务端在发送或接收数据时,既可以使用 write() 和 read() 来实现,也可以使用 send() 或 recv() 来实现。
#include<sys/type.h>
#include<sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
功能:向accept()返回的套接字中写入数据,用于服务端向客户端回应数据、客户端向服务端发送数据。
参数:1.sockfd:待写入数据的文件描述符。
2.buf:指向待发送的数据。
3.len:待发送数据的字节数。
4.flags:发送的方式,一般设置为0,表示阻塞式发送。
返回值:写入成功则返回实际写入的字节数;写入则失败返回-1,并设置合适的错误码。
#include<sys/type.h>
#include<sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
功能:从accept()返回的套接字中读取数据,用于服务端读取客户端发来的数据、客户端向服务端回应数据。
参数:1.sockfd:待读取数据的文件描述符。
2.buf:将读取到的数据存储 buf 所指的位置。
3.len:待读取数据的字节数。
4.flags:发送的方式,一般设置为0,表示阻塞式读取。
返回值:返回值 > 0 时,表示实际读取到的字节数。
返回值 = 0 时,表示对端(客户端)已关闭连接,服务端不再为其提供服务。
返回值 < 0 时,表示读取失败,并设置合适的错误码。
- server.cc
#include "protocol.hpp"
//线程例程
void* Routine(void* arg);
//主线程
int main(int argc, char* argv[])
{
if (argc != 2){
cerr << "Usage: " << argv[0] << " port" << endl;
exit(1);
}
int port = atoi(argv[1]);
//1.创建套接字
//使用 IPv4 的协议簇、使用 TCP 协议
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
exit(2);
}
//2.绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
exit(3);
}
//3.监听
if (listen(listen_sock, 5) < 0){
cerr << "listen error!" << endl;
exit(4);
}
//4.运行
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
while(1){
//(1)获取数据请求
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
cerr << "accept error!" << endl;
continue;
}
//(2)创建子线程处理数据请求
pthread_t tid = 0;
int* p = new int(sock);
pthread_create(&tid, nullptr, Routine, p);
}
return 0;
}
void* Routine(void* arg)
{
//分离线程
pthread_detach(pthread_self());
//提供服务
int sock = *(int*)arg;
delete (int*)arg;
while (1){
request_t rq;
ssize_t size = recv(sock, &rq, sizeof(rq), 0);
if (size > 0){
//计算
response_t rp = { 0, 0 };
switch (rq.op){
case '+':
rp.result = rq.x + rq.y;
break;
case '-':
rp.result = rq.x - rq.y;
break;
case '*':
rp.result = rq.x * rq.y;
break;
case '/':
if (rq.y == 0){
rp.code = 1; //除0错误
}
else{
rp.result = rq.x / rq.y;
}
break;
case '%':
if (rq.y == 0){
rp.code = 2; //模0错误
}
else{
rp.result = rq.x % rq.y;
}
break;
default:
rp.code = 3; //非法运算
break;
}
//向客户端响应结果
send(sock, &rp, sizeof(rp), 0);
}
else if (size == 0){
cout << "service done" << endl;
break;
}
else{
cerr << "read error" << endl;
break;
}
}
close(sock);
return nullptr;
}
.3- 客户端
- client.cc
#include "protocol.hpp"
int main(int argc, char* argv[])
{
if (argc != 3){
cerr << "Usage: " << argv[0] << " server_ip server_port" << endl;
exit(1);
}
string server_ip = argv[1];
int server_port = atoi(argv[2]);
//1.创建套接字
//使用 IPv4 的协议簇、使用 TCP 协议
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
cerr << "socket error!" << endl;
exit(2);
}
//2.连接服务器
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(server_port);
peer.sin_addr.s_addr = inet_addr(server_ip.c_str());
if (connect(sock, (struct sockaddr*)&peer, sizeof(peer)) < 0){
cerr << "connect failed!" << endl;
exit(3);
}
//3.运行
while (1){
//构建请求
request_t rq;
cout << "请输入左操作数# ";
cin >> rq.x;
cout << "请输入右操作数# ";
cin >> rq.y;
cout << "请输入需要进行的操作[+-*/%]# ";
cin >> rq.op;
//发送请求
send(sock, &rq, sizeof(rq), 0);
//接收服务端的响应数据
response_t rp;
recv(sock, &rp, sizeof(rp), 0);
cout << "status: " << rp.code << endl;
cout << rq.x << rq.op << rq.y << "=" << rp.result << endl;
}
return 0;
}
.4- 测试效果
- Makefile
.PHONY:all
all:client server
client:client.cc
g++ -o $@ $^ -std=c++11
server:server.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f client server
二、HTTP 协议
在进行网络编程时,协议可以自行定制,也可以使用前人的定制。有很多优秀的工程师已经写出了许多非常成熟的应用层协议,其中最典型的就是 HTTP 协议。
HTTP 协议(Hyper Text Transfer Protocol)又叫做超文本传输协议,通常运行在 TCP 之上,是一个简单的“请求—响应”协议。
1)网址 URL
URL(Uniform Resource Lacator)是统一资源定位符,也就是通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
【Tips】URL 的构成
.1- 协议方案名
http:// 所指的就是协议方案名,表示请求时需要使用的协议。
通常使用的是 HTTP 协议或 HTTPS 安全协议。其中,HTTPS 是以安全为目标的 HTTP 通道,在HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性。
【补】常见的应用层协议
- 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):简单文件传输协议。
.2- 登录信息
usr:pass 表示的是登录认证信息,其中包括登录用户的用户名和密码。
虽然登录认证信息可以在 URL 中体现出来,但在绝大多数 URL 中,这个字段都是被省略的,这是因为登录信息可以通过其他方案交付给服务器。
.3- 服务器地址
www.example.jp 表示的是服务器地址,也叫做域名,又例如 www.qq.com、www.baidu.com等。
【Tips】域名 vs IP 地址
IP 地址虽然可以标识公网内的任意一台主机,但本身并不适合让用户看。
例如 www.baidu.com 这个域名解析后的 IP 地址为 153.3.238.110(指令 ping 可以获取域名解析后的 IP 地址),如果用户在访问网站时看到的是这个 IP 地址,那么 ta 在访问网站之前,恐怕都不清楚这个网站到底是做什么的,但如果用户看到的是 www.baidu.com 这个域名,那么 ta 可以至少知道,这个网站是属于哪家公司的。
因此,相比 IP 地址,域名具有更好的自描述性。
但在计算机中,域名和 IP 地址其实几乎是等价的,两者的使用效果相同,只不过,URL 要呈现给用户,所以 URL 选择了域名来表示服务器地址。
.4- 服务器端口号
80 表示的就是服务器端口号。
HTTP 协议和套接字编程一样,都是位于应用层的。在进行套接字编程时,需要给服务器绑定对应的 IP 地址和端口号,而这里的应用层协议也同样需要有明确的端口号。
用户使用的协议本质是要为用户提供服务的,因此也需要绑定端口号。不过如今,常用的协议与端口号之间的对应关系基本已经明确的,于是在使用某种协议时,已经无需指明该协议对应的端口号。因此在 URL 中,服务器的端口号一般也是被省略的。
【Tips】常见协议的端口号
.5- 带层次的文件路径
/dir/index.htm 表示的就是要访问的资源所在的路径。
访问服务器的目的本质是从服务器上获取某种资源,虽通过前面的域名和端口号已经能够找到对应的服务器进程了,但接下来还要指明资源所在的路径。
例如,打开浏览器并输入百度的域名,浏览器会获取到百度的首页。
发起网页请求时,会获得如下的网页信息,然后浏览器对这张网页信息进行解释,最终呈现出了对应的网页。
任意网页按下 F12 键即可查看:
这种网页信息就是所谓的网页资源。
此外还可以看到,网页信息中的路径分隔符是 /
而不是 \
,这也就说明其实很多服务都是部署在 Linux 上的。
除了发起网页请求以打开网页,还可以向服务器请求视频、音频、网页、图片等非普通文本资源,这也是 HTTP 叫做超文本传输协议的原因。
.6- 查询字符串
uid=1 表示的就是请求时提供的额外的参数。这些参数是以键值对的形式,通过符合 & 分隔开的。
例如,在百度上搜索 HTTP 时,可以看到 URL 中的很多参数,其中有一个参数 wd(word),就表示搜索时的关键字 wd=HTTP。
由此,进行网络通信时,通信双方其实是可以通过 URL 来进行用户数据传输的。
.7- 片段标识符
ch1 表示的就是片段标识符,是对资源的部分补充。
当需要在网址中定位某一个网页的位置时,可以通过片段标识符来定位。
【Tips】获取片段标识符的方法
- 在网页中右键,选择选项中的检查,查看网页源代码。
- 按下 ctrl + f 在搜索框 搜索 id 这个标签。
2)urlencode 和 urldecode
像 /?:
等这样的字符, 已经被 URL 当做特殊意义理解了,因此这些字符不能随意出现。
如果某个参数中需要带有这些特殊字符,,就必须先对特殊字符进行转义。
【Tips】转义规则:
- 将需要转码的字符转为十六进制;
- 从右到左,取4位(不足4位直接处理);
- 每两位做一位,前面加上%,编码成%XY格式。
例如,在百度上搜索C++,由于 + 在 URL 中是特殊符号,因此需对 + 进行转义。由转义规则, + 的十六进制的值为 0x2B,由此,+ 就会被编码成一个 %2B。
【ps】URL 中除了会对这些特殊符号做编码,也会对中文进行编码。
【补】在线编码工具
UrlEncode编码/UrlDecode解码 - 站长工具
- 选中 URL 编码/解码模式,在输入 C++ 后点击编码就能得到编码后的结果:
- 对编码进行解码后的结果:
【ps】服务器拿到 URL 后,也需要对编码后的参数进行解码,才能拿到用户传递的参数。解码本身也是编码的逆过程。
3)HTTP 协议格式
网络协议栈是分层,每一层都有属于自己的协议。
TCP/IP 分层模型中包含了应用层、网络层、传输层、数据链路层等。其中,应用层常见的协议有 HTTP 和 HTTPS,传输层有 TCP 和 UDP,网络层有 IP,数据链路层有 MAC 帧。
应用层负责如何使用传输过来的数据,应用层的下三层则负责通信细节。下三层的通信细节是由操作系统或驱动来完成的,如果仅考虑应用层而不考虑下三层,那么就可以认为己方的应用层能够和对方的应用层直接进行数据交互。
要使用传输过来的数据,就需要用到定制的协议,其中最典型的就是 HTTP 协议。
HTTP 是基于请求和响应的应用层服务。作为客户端,可以向服务器发起数据请求(request),当服务器收到数据请求后,会对请求做数据分析,得出客户端要访问什么资源,然后再构建数据响应(response),以完成这一次 HTTP 的请求。这种基于“request & response”的工作方式,被称之为 cs 模式(client & server)或 bs 模式(browser & server)。
由于 HTTP 是基于请求和响应的应用层访问,因此对应的请求格式和响应格式是十分重要的。
.1- 请求协议格式
【Tips】HTTP 请求由以下四部分组成:
- 请求行:包括请求方法、url 网址、http版本。
- 请求报头:请求的属性,以“key: value”形式按行陈列。
- 空行:表示请求报头结束。
- 请求正文:允许为空字符串。如果请求正文存在,则其中会有一个Content-Length属性来标识其长度。
前三部分是一般是 HTTP 协议自带的,由 HTTP 协议自行设置。第四部分请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,请求正文就为空字符串。
【补】按 \n 分离 HTTP 的报头与有效载荷
在网络通信中,在 TCP/IP 模型的每一层中都存在一种协议,而每一种协议的最终表现就是协议要有协议报头,也就是说,协议通常是通过协议报头来表达的,且每一份数据在被发送或者处于不同的协议层中,都要有自己的报头。数据在 TCP/IP 模型中以报文的形式传递,而报文由报头和有效载荷组成。对于报头和有效载荷,TCP/IP 模型的每一层都得必须具备将其封装与分离的能力,只有这样才能将这一层有用的数据封装起来或解析出来。
因此,当应用层收到一个 HTTP 请求时,它必须想办法分离 HTTP 的报头与有效载荷以获取关键数据。
在 HTTP 请求的组成中,请求行和请求报头其实就是 HTTP 的报头信息,而请求正文其实就是 HTTP 的有效载荷。
一般可以根据 HTTP 请求中的空行来分离报头与有效载荷。当服务器收到一个 HTTP 请求后,就按行进行读取,如果读取到空行,则说明此时已将报头读取完毕。如果将 HTTP 请求想象成一个大的线性结构,那么每行的内容都是用 \n 隔开的,因此在读取过程中,如果连续读取到了两个 \n,就说明已经将报头读取完毕了,后面剩下的就都是有效载荷了。
.补- 模拟获取浏览器的 HTTP 请求
- http_server.cc
#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()
{
//1.创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
return 1;
}
//2.绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888); //绑定端口号为 8888
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
return 2;
}
//3.监听
if (listen(listen_sock, 5) < 0){
cerr << "listen error!" << endl;
return 3;
}
//4.运行
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(1){
//爷爷进程负责获取 HTTP 请求
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);
}
//孙子进程负责读取和打印 HTTP 请求
char buffer[1024];
recv(sock, buffer, sizeof(buffer), 0);
cout << "[ http request begin ]" << endl << endl;
cout << buffer << endl;
cout << "[ http request end ]" << endl << endl;
close(sock);
exit(0);
}
close(sock);
waitpid(-1, nullptr, 0);
}
return 0;
}
①服务器运行之初
②用浏览器(Microsoft Edge)登陆云服务器的IP地址+端口号
.2- 响应协议格式
【Tips】HTTP 响应由以下四部分组成:
- 状态行:包括 http 版本、状态码、状态码描述。
- 响应报头:响应的属性,以“key: value”的形式陈列。
- 空行:表示响应报头结束。
- 响应正文:允许为空字符串。如果响应正文存在,则其中会有一个 Content-Length 属性来标识响应正文的长度。例如服务器返回了一个 html 页面,那么返回的 html 页面的内容就是在响应正文中的。
.补- 模拟构建HTTP并响应给浏览器
- http_server.cc
#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()
{
//1.创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
return 1;
}
//2.绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888); //绑定端口号为 8888
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
return 2;
}
//3.监听
if (listen(listen_sock, 5) < 0){
cerr << "listen error!" << endl;
return 3;
}
//4.运行
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(1){
//爷爷进程负责获取 HTTP 请求
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);
}
//孙子进程
//读取和打印 HTTP 请求
char buffer[1024];
recv(sock, buffer, sizeof(buffer), 0);
cout << "[ http request begin ]" << endl << endl;
cout << buffer << endl;
cout << "[ http request end ]" << endl << endl;
//构建一个简单的 HTTP 响应
std::string HttpResponse = "HTTP/1.1 200 OK\r\n"; // 状态行
HttpResponse += "\r\n"; // 空行
HttpResponse += "<html><h1>Hello NeeEk0 </h1></html>"; // 正文
//发送 HTTP 响应
send(sockfd, HttpResponse.c_str(), HttpResponse.size(), 0);
close(sock);
exit(0);
}
close(sock);
waitpid(-1, nullptr, 0);
}
return 0;
}
①服务端运行之初
②用浏览器(Microsoft Edge)登陆云服务器的IP地址+端口号
服务端收到并打印了浏览器的请求信息。
浏览器收到并显示了服务端的响应信息。
4)常用方法
GET 和 POST 是最常用的 HTTP 方法。
【Tips】GET 和 POST
GET 方法:
- 一般用于获取某种资源信息。
- 通过 url 传参,会将参数回显到 url 中。
POST 方法:
- 一般用于将数据上传给服务器。但上传数据时也有可能使用 GET 方法,例如百度提交数据时使用的其实是 GET 方法。
- 通过正文传参,由此能传递更多的参数数据。此外,不会将参数回显到 url 中,因此更加私密。
【ps】POST 方法不会将参数回显到 url 中,并不意味着 POST 方法更安全。实际上 GET 和POST 方法传参时都是明文传送,因此都不安全,只不过 POST 方法的传参更私密。
以下借助 TCP 套接字来演示 GET 和 POST 的区别。
为方便演示,此处在 TCP 服务端进程所在的目录下创建一个 html 文件,并修改上文服务端的代码,将响应内容设置为 html 文件中的内容。这样一来,浏览器访问服务端后,显示就是 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>TestGET</title>
</head>
<body>
<h1>测试GET</h1>
<p> /* 段落部分 */</p>
<form name="input" action="/a/b/c" method="GET">
Username: <input type="text" name="user">
<br/>
Password: <input type="password" name="pwd">
<br/>
<input type="submit" value="Submit">
</form>
</body>
</html>
其中,action 属性指定将表单提交给服务端上的哪个资源,method 属性指定参数提交的方法为 GET。
- http_server.cc
#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;
#define PAGE "index.html"
int main()
{
//1.创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
return 1;
}
//2.绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888); //绑定端口号为 8888
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
return 2;
}
//3.监听
if (listen(listen_sock, 5) < 0){
cerr << "listen error!" << endl;
return 3;
}
//4.运行
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(1){
//爷爷进程负责获取 HTTP 请求
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);
}
//孙子进程
//读取和打印 HTTP 请求
char buffer[1024];
recv(sock, buffer, sizeof(buffer), 0);
cout << "[ http request begin ]" << endl << endl;
cout << buffer << endl;
cout << "[ http request end ]" << endl << endl;
//读取 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;
}
将上文中的 TCP 服务端运行,在浏览器上输入“云服务器 IP 地址:端口号”,即可访问这个 html 文件。
服务端收到了浏览器的请求:
浏览器显示了服务端的响应:
在浏览器中填充完用户名和密码并进行提交后,用户名和密码就会自动被同步到 url 中:
同时,服务端通过 url 也收到了在浏览器提交的参数。
接下来,将 html 文件中的 method 属性从 GET 改为 POST。
- index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TestPOST</title>
</head>
<body>
<h1>测试POST</h1>
<p> /* 段落部分 */</p>
<form name="input" action="/a/b/c" method="POST">
Username: <input type="text" name="user">
<br/>
Password: <input type="password" name="pwd">
<br/>
<input type="submit" value="Submit">
</form>
</body>
</html>
再次在浏览器上输入 “云服务器 IP 地址:端口号”:
此时,填充完用户名和密码并进行提交后,提交的参数不会在 url 中体现,而会通过正文传递给服务端:
5)状态码
最常见的状态码例如:
- 200(OK)
- 404(Not Found)
- 403(Forbidden请求权限不够)
- 302(Redirect)
- 504(Bad Gateway)
【补】重定向状态码(Redirection)
重定向是指。通过各种方法将各种网络请求转到其它位置,相当于服务器提供了一个引路服务。
它又可分为临时重定向和永久重定向,其中,状态码 301 表示的就是永久重定向,而状态码 302 和 307 表示的是临时重定向。
临时重定向和永久重定向本质是影响客户端的标签,可以决定客户端是否需要更新目标地址。
如果某个网站是永久重定向的,那么第一次访问该网站时就由浏览器帮助进行重定向,但后续再访问就无需浏览器进行重定向了,可以直接重定向后的网站;如果某个网站是临时重定向的,那么每次访问该网站时都需要进行重定向,那么就都需要浏览器来帮助完成重定向并跳转到目标网站。
【补】演示临时重定向
进行临时重定向时,需要用到 HTTP 报头中的 Location 字段,以表明了要重定向的目标网站,此处重定向为 CSDN 的首页。
- http_server
#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() { //1.创建套接字 int listen_sock = socket(AF_INET, SOCK_STREAM, 0); if (listen_sock < 0){ cerr << "socket error!" << endl; return 1; } //2.绑定 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(8888); //绑定端口号为 8888 local.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){ cerr << "bind error!" << endl; return 2; } //3.监听 if (listen(listen_sock, 5) < 0){ cerr << "listen error!" << endl; return 3; } //4.运行 struct sockaddr peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); while(1){ //爷爷进程负责获取 HTTP 请求 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); } //孙子进程 //读取和打印 HTTP 请求 char buffer[1024]; recv(sock, buffer, sizeof(buffer), 0); cout << "[ http request begin ]" << endl << endl; cout << buffer << endl; cout << "[ http request end ]" << endl << endl; //构建 HTTP 响应 //将状态码设置为 307,并跟上相应的状态码描述 string status_line = "http/1.1 307 Temporary Redirect\n"; //重定向为 CSDN 的首页 string response_header = "Location: https://www.csdn.net/\n"; string blank = "\n"; string response = status_line + response_header + blank; //响应 HTTP 请求 send(sock, response.c_str(), response.size(), 0); close(sock); exit(0); } close(sock); waitpid(-1, nullptr, 0); } return 0; }
6)常见报头
【Tips】HTTP 常见的报头
- Content-Type:数据类型(text/html等)。
- Content-Length:正文的长度。
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
- User-Agent:声明用户的操作系统和浏览器的版本信息。
- Referer:当前页面是哪个页面跳转过来的。
- Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
【Tips】Host 字段
Host 用于表明客户端要访问的服务器 IP 和端口。
有一些服务器实际提供的是一种代理服务,也就是代替客户端向其他服务器发起请求,然后将请求结果再返回给客户端,在这种情况下,客户端就必须告诉代理服务器它要访问的 IP 和端口,以指明它真正要访问的服务器。
【Tips】User-Agent 字段
User-Agent 用于表示客户端的操作系统和浏览器的版本信息。
在用网站下载某些软件时,网站一般会自动展示与操作系统相匹配的软件版本,这是因为,在向目标网站发起请求时,User-Agent 字段中包含了主机信息,使网站能够推送合适的软件版本。
【Tips】Referer 字段
Referer 能够记录访问的上一个页面,用于表示当前是从哪一个页面跳转过来的。
Referer 的优点一方面是方便回退,另一方面可以让用户知道当前页面与上一个页面之间的相关性。
【Tips】Keep-Alive(长连接)字段
如果 HTTP 请求或响应报头中,Connect 字段的值是 Keep-Alive,就表示支持长连接。
HTTP/1.0 是通过“请求—响应”的方式来进行请求和响应的,常见的工作方式就是客户端先与服务器建立链接,并向服务器发起请求,然后服务器对请求进行响应,并进行端口连接。
但如果一个连接建立后,客户端和服务器只进行一次交互就将连接关闭,未免太浪费资源,因此,现在主流的 HTTP/1.1 是支持长连接的。
所谓的长连接,就是建立连接后,客户端可以不断的向服务器一次写入多个 HTTP 请求,而服务器可以在上层依次读取这些请求。这样一来,一条连接就可以传送大量的请求和响应。
7)Cookie 和 Session
HTTP 是一种无状态协议,也就是说。HTTP 的每次请求与响应之间是没有任何关系的。
然而,在使用浏览器的时候发现并不是这样的,例如在 CSDN 网站上登录过一次个人账号,就算把网站关闭甚至重启电脑,再次打开网站时,个人账户仍然登录在案,且网站并没有要求再次输入账号和密码。
这其实是通过 cookie 技术实现的。
点击浏览器中锁的标志,就可以看到当前网站的各种 cookie 数据。
这些 cookie 数据都是由相应的服务器来写的,如果将某些登录时所设置的 cookie 信息删除,那么就可能要重新进行登录认证了。
.1- Set-Cookie 字段与 cookie 文件
cookie 技术可以在第一次认证登录后,让后续所有的认证都变成自动认证 。
在第一次登录某个网站时,一般都需要输入账号和密码进行身份认证,如果服务器经过数据比对后判定登录的用户是合法的,且为了之后用户在进行某些网页请求时不必重新输入账号和密码,服务器就会进行 Set-Cookie(也是 HTTP 报头当中的一种属性信息) 的设置。
通过认证并设置好 Set-Cookie 后,等服务器需要对浏览器进行 HTTP 响应时,服务器就会将设置好的 Set-Cookie 响应给浏览器。浏览器收到响应后,会自动提取出 Set-Cookie 的值,并将其保存在浏览器的 cookie 文件中,相当于将账号和密码信息都保存在本地浏览器的 cookie 文件中。
至此,浏览器再次向网站发起的 HTTP 请求中,就会自动包含一个 cookie 字段,其中携带的就是第一次登录的认证信息。对端服务器能够直接提取出 HTTP 请求中的cookie字段,使用户在进行某些网页请求时不必重新输入账号和密码。
cookie 其实就是在浏览器中的一个小文件,其中记录了用户的私有信息。
cookie 文件一般分为两种,一种是内存级别的 cookie 文件,另一种是文件级别的 cookie 文件。
- 关闭浏览器再打开,访问之前登录过的网站,如果需要重新输入账号和密码,则说明之前登录时,浏览器保存的 cookie 信息是内存级别的。
- 将浏览器关掉甚至将电脑重启再打开,访问之前登录过的网站,如果不需要重新输入账户和密码,则说明之前登录时,浏览器保存的 cookie 信息是文件级别的。
.2- cookie 被盗与 SessionID
cookie 被盗其实说明的是 cookie 信息与其所涉及的用户身份之间的关系。
如果一个用户浏览器中保存的cookie信息被另一个非法用户盗取了,那么非法用户就可以通过用户的 cookie 信息,以用户的身份去访问用户曾经访问过的网站。
因此单纯地使用 cookie 并不安全。
为了避免 cookie 文件泄漏,其中的私密信息也连带着泄漏,当前主流的服务器引入了 SessionID 这样的概念。
在第一次登录某个网站时,服务器认证成功后还会生成一个对应的 SessionID,在进行HTTP 响应时,会将这个生成的 SessionID 的值响应给浏览器,然后浏览器会将其保存在自己的cookie文件中,使后续访问服务器时,发起的 HTTP 请求中自动携带上这个 SessionID,以辨识用户是否曾经登录过。
这个 SessionID 与用户信息并不相关,所有登录用户的 SessionID 会由系统来统一维护。
在引入 SessionID 之前,用户登录的账号信息都是保存在浏览器内部的,账号信息是由客户端去维护的;引入 SessionID 后,用户登录的账号信息是由服务器去维护的,在浏览器内部保存的只是 SessionID。
在引入 SessionID 之后,虽然浏览器中的 cookie 文件保存的是 SessionID 而不再是账户密码了,但 SessionID 仍然可能会因 cookie 文件被盗取而被盗取,非法用户仍可以通过盗取到的 SessionID 去访问其他用户曾经访问过的服务器。
虽然没有真正解决安全问题,SessionID 仍有可能因 cookie 文件被盗而被盗,但服务器已经使用各种各样的策略来保护用户账号,已经是相对安全的了。
- 可以通过IP地址的类别来判断登录用户所在的地址范围。如果一个账号在短时间内登录地址发送了巨大变化,服务器就会立马识别到这个账号发生异常了,然后清除对应的SessionID的值。此时非法用户或原本的用户想要访问服务器,就都需要重新输入账号和密码进行身份认证。当用户重新认证登录后服务器,还可以将另一方识别为非法用户,进而对该非法用户进行对应的黑名单/白名单认证。
- 进行某些高权限的操作时,要求操作者再次输入账号和密码信息,再次确认身份。有了 SessionID ,非法用户就无法在短时间内获取账户密码,如果在修改密码时仍需要输入旧密码,那么非法用户就无法在短时间内修改原本的密码,用户还可以通过追回的方式让当前的 SessionID 失效,让操作者重新进行登录认证。
- SessionID 也有过期策略。例如,如果 SessionID 是一个小时内是有效的,即便被盗取了,也仅仅是在一个小时内有效,且在功能上受约束,这样就不会造成太大的影响。
【补】网络中,信息的安全是相对的
互联网上并不存在绝对的信息安全,任何信息安全都是相对的,就算将发送到网络中的信息进行加密,也存在被人破解的可能。
除非某个信息的破解成本已经远大于破解后的收益,也就是说破解这个信息是赔本的,那才可以说这个信息是安全的。
.补- 实验演示 Set-Cookie 字段
如果服务器给浏览器的 HTTP 响应当中包含 Set-Cookie 字段,那么浏览器再次访问服务器时,就会携带上这个 cookie 信息。
此处对于上文的服务器代码,在服务器的响应报头中添加一个 Set-Cookie 字段,以验证浏览器第二次发起 HTTP 请求时会带上这个添加的 Set-Cookie 字段。
- http_server.cc
#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;
#define PAGE "index.html" //网站首页
int main()
{
//1.创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0){
cerr << "socket error!" << endl;
return 1;
}
//2.绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888); //绑定端口号为 8888
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
return 2;
}
//3.监听
if (listen(listen_sock, 5) < 0){
cerr << "listen error!" << endl;
return 3;
}
//4.运行
struct sockaddr peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(1){
//爷爷进程负责获取 HTTP 请求
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);
}
//孙子进程
//读取和打印 HTTP 请求
char buffer[1024];
recv(sock, buffer, sizeof(buffer), 0);
cout << "[ http request begin ]" << endl << endl;
cout << buffer << endl;
cout << "[ http request end ]" << endl << endl;
//读取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"; //响应报头
//在响应报头中添加 Set-Cookie 字段
response_header += "Set-Cookie: NeeEk0\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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TestPOST</title>
</head>
<body>
<h1>Hello HTTP</h1>
<p> /* 段落部分 */</p>
<form name="input" action="/a/b/c" method="GET">
Username: <input type="text" name="user">
<br/>
Password: <input type="password" name="pwd">
<br/>
<input type="submit" value="Submit">
</form>
</body>
</html>
浏览器访问“云服务器 IP + 端口号”,输入账户密码后点击提交:
服务器效果:
此时,浏览器的第二次 HTTP 请求会携带这个 cookie 信息。
三、HTTPS 协议
1)HTTPS 与 HTTP
在计算机行业早期,很多公司所使用的应用层协议都是 HTTP。但 HTTP 无论是用 GET 方法传参还是 POST 方法传参,数据都是没有经过任何加密的,使得很多信息可以通过抓包工具获取到。于是,为了解决这个安全问题,后来就出现了 HTTPS 协议。
HTTPS 在应用层和传输层协议之间增加了一层加密层(SSL & TLS,本身也是属于应用层)的,在交付数据时先把数据交给加密层,由加密层对数据加密后再交给传输层,以此完成对用户的个人信息进行各种程度的加密;通信双方使用的应用层协议需一致,对端的应用层也要使用 HTTP,当对端的传输层收到数据后,会先将数据交给加密层,由加密层对数据进行解密后再将数据交给应用层,以此完成对用户的个人信息的解密。
数据只有在应用层没有被加密的,而在应用层往下和网络中都是加密的,这就是 HTTPS 协议的作用。
2)对称加密与非对称加密
一般地,加密的方式可以分为两种:
- 对称加密:采用单钥密码系统的加密方法,同一个密钥可以同时用作信息的加密和解密。
- 非对称加密:采用公钥和私钥来进行加密和解密,用其中一个密钥进行加密就必须用另一个密钥进行解密。
【Tips】对称加密
由于对称加密一般比非对称加密效率更高,因此双方在正常通信时一般也使用的是对称加密。
进行对称加密通信,需要加密方把密钥给解密方,使解密方可以对数据正常进行解密,但密钥本身也是数据,也是需要加密的,这反而就变成了鸡生蛋还是蛋生鸡的问题了,因此,在刚开始进行密钥协商的时候,就需要用到非对称加密了。
- 通信双方建立连接的时候,可以协商好加密算法,并在服务端去形成非对称加密时使用的公钥和私钥、在客户端去形成对称加密时使用的密钥。
- 在非对称加密中,数据用公钥加密就必须用私钥解密,而只有服务端才有私钥,其他客户端都只有公钥,因此,客户端用公钥加密后的密钥只有服务器能够解密。
- 生成好公钥、私钥、密钥后,服务端会将公钥交给客户端(这个公钥全世界都可以看到),客户端会用这个公钥对客户端形成的密钥进行加密,然后将加密后的密钥发送给服务端,服务端拿到后再用私钥进行解密,就可以拿到客户端的密钥,且其他人是不知道的这个密钥。至此,客户端和服务端就可以进行对称加密通信了。
对称加密中最典型的例子就是异或运算。例如,用 A 异或 B 就能得到一个中间值 C,而又用 C 异或 B 就能重新得到 A 。其中,A 就相当于双方的通信数据,B 就相当于对称加密中的密钥,C 就相当于被密钥加密后的数据。