1. 认识HTTP协议
HTTP(Hyper Text Transfer Protocol)协议又叫做超文本传输协议,是一个简单的请求-响应协议,HTTP通常运行在TCP之上。
超文本的意思就是超越普通的文本,http允许传送文字,图片,视频,音频等,协议我们前面也说过,就是一种约定。
HTTP协议在应用层,主要是解决如何处理对端发送过来的数据,关于对端数据如何发送过来,丢包了怎么办等问题,不是应用层要考虑的,在传输层,网络层,数据链路层会帮我们解决这些问题,他只关心如何处理对端数据。
所以站在应用层的角度来看,就是双方的应用层之间直接进行通信
2. 认识URL
URL叫做统一资源定位符, 平时我们俗称的 "网址" 其实就是说的 URL。一个URL通常由以下几个部分构成
- 协议方案名:最常见的就是http和https
- 登录信息:包含用户名和密码,但是一般不会在URL中体现出来
- 服务器地址:其实就是IP地址,只不过是被DNS服务解释成了域名
- 服务器端口号:一般不用在URL中体现出来,因为当我们使用某种协议时,该协议实际就是在为我们提供服务,现在这些常用的服务与端口号之间的对应关系都是明确的,所以我们在使用某种协议时实际是不需要指明该协议对应的端口号的,因此在URL当中,服务器的端口号一般也是被省略的。
- 带层次的文件路径:我们需要访问的资源在服务器的哪个位置例如上面的/dir/index.htm意思就是,我们要访问/dir/index目录下的.htm文件。而 /dir 并不是从系统根目录开始的,而是从web根目录开始(服务器会自动在为 /dir 添加文件路径)
- 查询字符串:就是我们要给服务器传递的参数中间以&分隔。
- 片段标识符:对资源部分的补充
举个例子:我们使用百度搜索http
显示的URL
我们对其划分
https就是协议方案名,服务器地址是www.baidu.com,文件路径是/s,后面的就是查询字符串了,也就是要传递给服务器的参数,我们仔细观察会发现查询字符串中有一个wd=http,其实就是我们刚才要搜索的内容
2.1 urlencode和urldecode
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现.比如, 我们使用百度搜索"?fsda*"
显示的wd我们会发现,除了?被转化成了%3F之外,其他字符都能成功显示出来。
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式
urlencode就是将特殊字符进行转义(编码),而urldecode就是将转义后的特殊字符转化回去。(解码)。
3. HTTP协议格式
HTTP是应用层协议,主要是分析对端传来的数据进行分析,得出你想要的资源,并且返回给你。例如上面的例子,我们使用百度搜索http的过程,百度的服务器在应用层分析出我们想要搜索的内容是http,服务器经过比较复杂的一系列操作之后,将结果再返回给我们。这种模式我们可以称为cs模型,c就是客户端,通常指的是我们client用户(使用者),s就是server服务端(提供服务),也可以是bs模型,因为用户一般都是浏览器(通过浏览器访问目标资源)。
HTTP通过是基于TCP的,而TCP是面向字节流的,在传输数据过程中,所有数据都是粘在一起的,无法区分报文与报文之间的间隔,我们要通过协议的方式解决这个问题,而HTTP就是已经制定好,比价h成熟的协议了。所以我们也要学习一下,给服务端发送请求报文的格式与收到服务端响应报文的格式。
3.1 HTTP请求报文格式
- 请求行: [请求方法] + [url] + [版本]
- 请求报头: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
- 空行:标识报文读取结束
- 请求正文: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个Content-Length属性来标识Body的长度;
下面是几种比较常见的请求方法:
其中最重要的两个就是GET和POST方法。
注意,这里的url是web根目录,并不是Linux系统根目录,可以是机器上任何一个目录,由我们自己指定。例如我将来将所有的资源都保存在wwwroot目录下。我们在写服务端的时候让默认路径
std::string defaultpath = "./wwwroot";
浏览器给我们发送的url被提取之后,我们直接在url前面添加defaultpath。例如我们默认访问的是 / ,在添加之后就会变成
std::string url = "./wwwroot/";
我们后续可以进行处理,每个目录下我们都设置一个index.html的文件作为这个目录的首页,当我们查看url,发现他是一个目录的时候就让他默认访问这个目录下的index.html文件。
所以我们的url是 / 但是最终访问的资源其实是
std::string url = "./wwwroot/index.html";
这就是web根目录的作用,可以在指定目录下进行资源查找。
3.2 HTTP响应报文格式
- 状态行:[http版本]+[状态码]+[状态码描述]
- 响应报头:请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
- 空行:遇到空行表示响应报头结束。
- 响应正文:响应正文允许为空字符串,如果响应正文存在,则响应报头中会有一个Content-Length属性来标识响应正文的长度。
状态码和状态码描述:用于描述请求的结果
常见的状态码以及状态码描述:
- [200 OK 成功] 请求正常被服务器处理
- [204 Not Content] 请求处理成功但是没有资源可返回,相应报文中不含实体的主体部分,另外也不允许返回任何实体,浏览器返回的页面不发生更新一般在只需要客户端给服务端发消息,而对客户端不需要发送新内容的情况下使用
- [206 Partial Content] 表示客户端进行了范围请求,客户端只要某一部分的信息,服务器成功执行了这部分的GET请求相应报文中包含Content-Range指定范围的实体内容
- [301 Moved Permanently] 永久性重定向,资源移动会更新浏览器书签, 相应状态码返回时,所有浏览器都会把POST改成GET,并删除请求报文主体,之后请求会自动再次发送
- [302 Found] 临时性重定向,资源移动不会更新浏览器书签,相应状态码返回时,所有浏览器都会把POST改成GET,并删除请求报文主体,之后请求会自动再次发送
- [303 See Other] 资源的URI已经更新,临时按新的URI进行访问,使用GET请求获取相应的资源,相应状态码返回时,所有浏览器都会把POST改成GET,并删除请求报文主体,之后请求会自动再次发送
- [304 Not Modified] 自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。如果网页自请求者上次请求后再也没有更改过,您应将服务器配置为返回此响应(称为 If-Modified-Since HTTP 标头)。服务器可以告诉 Googlebot 自从上次抓取后网页没有变更,进而节省带宽和开销。返回时不包含任何请求的主体部分,304和重定向无任何关系
- [307 Temporary Redirect] 临时重定向,和302有相同的含义
- [400 Bad Request] 请求报文中存在语法错误,服务端无法理解,需修改请求的内容后再次发送请求,浏览器会像对待200一样对待该状态码
- [401 Unauthorized] 发送的请求需要有通过HTTP认证的认证信息。另外若之前已经已经进行过一次请求,则表示用户认证失败, 当浏览器初次接收到401响应,会弹出认证用的对话窗口
- [403 Forbidden] 服务器拒绝请求该资源, 或者表示未获取文件系统的访问授权,访问权限出现某些问题, 服务器端可以说明拒绝的理由,并返回给客户端展示
- [404 Not Found] 表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。
- [500 Interval Server Error] 服务器在执行请求时发生故障, 也可能是web应用存在的bug或者某些临时故障
- [503 Service Unavailable] 服务器目前超负载,正忙着呢,正在进行停机维护,现在无法处理请求
在HTTP请求报文和响应报文中都包含了版本这一字段,为什么要交互版本呢
请求报文发送的是客户端的http版本,响应报文发送的是服务端的版本,主要是为了能让不同版本的客户端都能享受到服务。客户端告诉服务端自己的版本,服务端就可以为客户端提供适合他的版本的服务。例如微信最初的版本是没有微信支付的功能的,如果我们一直使用的是最初版本不更新的话,那么服务端就不会给我们提供微信支付功能,只有把微信更新到一定版本才会支持。
3.3 报文中的常见字段
- Content-Type:数据类型(text/html等)。
- Content-Length:正文的长度。
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
- Connection:字段最常用于客户端要求服务器使用 TCP 持久连接,以便其他请求复用。
- User-Agent:声明用户的操作系统和浏览器的版本信息。
- Referer:当前页面是哪个页面跳转过来的。
- Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
- 1.Host字段:用于指明我们要访问资源的位置。
Host: www.A.com
- 2.Content-Length:正文的长度
Content-Length: 100
- 3.Content-Type:请求资源的类型,例如我们要请求一张网页就是html。
Content-Type: text/html
- 4.Connection:用于客户端要求服务器使用 TCP 持久连接,以便其他请求复用。
http/1.0采用的是短链接的方式,即浏览器(客服端)发起请求,建立连接,服务器构建响应之后立刻就会断开连接。但是对于一个网页,包含的元素较多则需要建立多次连接,频繁的建立连接断开连接的资源消耗比较大,TCP需要经历3次握手,4次挥手的过程。
http/1.1采用长连接,即建立连接后不再断开,客户端一次可以发多个请求。当连接双方超过一定时间没有数据交互时,就会自动断开连接。如果Connection字段是keep-alive就代表是长连接。
Connection: keep-alive
- 5.User-Agent:声明用户的操作系统和浏览器的版本信息。
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0
当我们想下载一个软件时,默认都会下载适合我们电脑型号的版本,就是因为我们会发送我们电脑的操作系统版本信息。
- 6.Cookie和Session
HTTP 是一种不保存状态, 即无状态(stateless) 协议。 HTTP 协议自身不对请求和响应之间的通信状态进行保存。 也就是说在 HTTP 这个级别, 协议对于发送过的请求或响应都不做持久化处理。
但是我们今天登录csdn后,把浏览器关闭,甚至关机重启后,我们会发现我们的账号还是登录上的,并没有要求我们再次输入密码,这就是Cookie技术。
我们可以在对应网站查看 cookie,如果把Cookie删除,下次登录就需要重新输入账号密码了。关于Cookie的内容后面会详细说,先简单了解一下。
3.4 如何将报头和有效载荷分离
http协议中,每一行的内容都以 \r\n 结尾,所以我们可以读取到 \n 说明我们读取到了这一行。我们可以使用按行读的方式,如果读到了某一行是空行,说明我们读到了一个完整报文,剩下的内容,直到读到 \n 都是正文内容,也可以通过读取报头中的Content-Length字段提取正文的大小进行读取。
4. HTTP请求与响应
4.1 获取浏览器的请求报头
我们可以使用代码来获取浏览器的请求报头看一下。
#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>int main()
{//创建套接字int listen_sock = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock < 0){std::cerr << "create socket error!" << std::endl;return 1;}std::cout << "create socket success" << std::endl;//绑定struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(8080);local.sin_addr.s_addr = htonl(INADDR_ANY);if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){std::cerr << "bind error!" << std::endl;return 2;}std::cout << "bind success" << std::endl;//监听if (listen(listen_sock, 10) < 0){std::cerr << "listen error!" << std::endl;return 3;}std::cout << "listen success" << std::endl;//启动服务器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){std::cerr << "accept error!" << std::endl;continue;}if (fork() == 0){close(listen_sock);if (fork() > 0){exit(0);}char buffer[1024];recv(sock, buffer, sizeof(buffer), 0); //读取HTTP请求std::cout << "--------------------------http request begin--------------------------" << std::endl;std::cout << buffer << std::endl;std::cout << "---------------------------http request end---------------------------" << std::endl;close(sock);exit(0);}close(sock);waitpid(-1, nullptr, 0);}return 0;
}
GET / HTTP/1.1
Host: 123.60.181.162:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
这里可以看到我们我们收到的报文就是这个格式,其实GET就是请求方法, / 是请求的url,http/1.1是版本。值得一提的是, / 指的是web根目录,并不是Linux系统根目录,可以是机器上任何一个目录,由我们自己指定。
请求报头中的字段都是 KV格式,例如Host表示被请求资源方的IP和端口,其中Host就是属性名,后面的才是真正的IP和端口。而这个请求报文中并没有出现Content-Length属性,说明没有正文部分的内容。
4.2 给浏览器发送响应报头
在收到浏览器的请求之后,我们可以构建一个报头给浏览器响应回去。
#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>int main()
{// 创建套接字int listen_sock = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock < 0){std::cerr << "create socket error!" << std::endl;return 1;}std::cout << "create socket success" << std::endl;// 绑定struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(8080);local.sin_addr.s_addr = htonl(INADDR_ANY);if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0){std::cerr << "bind error!" << std::endl;return 2;}std::cout << "bind success" << std::endl;// 监听if (listen(listen_sock, 10) < 0){std::cerr << "listen error!" << std::endl;return 3;}std::cout << "listen success" << std::endl;// 启动服务器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){std::cerr << "accept error!" << std::endl;continue;}if (fork() == 0){close(listen_sock);if (fork() > 0){exit(0);}char buffer[1024];recv(sock, buffer, sizeof(buffer), 0); // 读取HTTP请求std::cout << "--------------------------http request begin--------------------------" << std::endl;std::cout << buffer << std::endl;std::cout << "---------------------------http request end---------------------------" << std::endl;// 读取index.html文件std::ifstream in("./index.html");if (!in.is_open()){std::cerr << "open file error " << std::endl;}std::string line;std::string file;while (std::getline(in, line)){file += line;}// 构建HTTP响应std::string status_line = "http/1.1 200 OK\n"; // 状态行std::string response_header = "Content-Length: " + std::to_string(file.size()) + "\n"; // 响应报头std::string blank = "\n"; // 空行std::string response_text = file; // 响应正文std::string response = status_line + response_header + blank + response_text; // 响应报文// 响应HTTP请求send(sock, response.c_str(), response.size(), 0);close(sock);exit(0);}close(sock);waitpid(-1, nullptr, 0);}return 0;
}
代码的逻辑很简单,只是在接受到浏览器的请求报头之后,打开当前目录下的index.html文件。index.html文件内容也很简单
我们来测试一下最终的效果。
服务器启动,浏览器访问
请求报文如图所示,因为是长连接,所以有多个http请求。客户端请求后,服务端会构建http响应。
最终也是成功收到了我们想要的html资源。
4.3 使用telnet
我们可以使用telnet命令进行抓包,给服务器发送请求,并接受服务器的响应
可以看到我们现在给服务器响应的内容只有请求行和一个空行。
按下回车之后就可以看到响应报头了,包括状态行,响应报头以及正文部分,正文部分对应的内容其实就是html网页,浏览器会自动帮我们进行渲染
我们打开开发者模式就可以看到,这段html代码就是服务端的响应正文。
5. GET和POST方法
前面说过请求方法中最重要的两种方法就是GET和POST。
首先我们要认识,上网行为无非就是两种:
1.从网络中获取数据。
2.将数据上传到网络上
而将数据上传到网络我们通常使用的是GET获取POST方法,而从网络中获取数据一般使用的是GET方法。
两者的主要区别是
- GET方法是通过url传参的。
- POST方法是通过正文传参的。
我们先了解一下html中的表单。
我们先简单写一个表单的代码
<html><head></head><body><h1>Hello World</h1><form action="/a/index.html" method="GET">user:<br><input type="text" name="xname"><br>password:<br><input type="password" name="ypwd"><br></br><input type="submit" value="Log in"></form></body>
</html>
最终效果:
我们可以看到表单form中有一个属性method就是GET,说明此时我们使用的是GET方法。GET方法使用url传参。
当我们使用我们在表单中输入了账号和密码之后,点击Log in登录,
我们会发现url中果然有一个xname=zhangsan和ypwd=12345,这都是我们刚才输入的值。
所以服务端自然也可以根据url提取出账号和密码了。
如果是POST方法呢,POST方法通过正文传参
首先url中没有数据了。
我们可以看到在最后的正文部分果然是有账号和密码的。
所以我们可以知道,GET和POST的区别在于传参的位置不同,GET方法通过url传参,POST方法通过正文传参
那么问题来了,GET方法很容易被看到,POST方法不容易被看到,那么POST方法是不是比GET方法更安全?
其实无论是GET方法还是POST方法都是不安全的。POST方法虽然不容易被看到,但是不排除会被抓包,数据都是明文的,还是不安全,只能说POST方法比GET方法更加私密。安全这方面要看加密和解密。
6. Cookie和Session
HTTP 是无状态协议, 它不对之前发生过的请求和响应的状态进行管理。
那么现在会出现一个问题。假设我们要登录某视频网站观看VIP用户才能看的电影《战狼》
此时已经认证成功,接下来应该是登录此账号然后观看,但是因为跳转到登录页面,并且是在登录页面输入的账号密码,返回到主页面时并不会保存你的账号密码,因为http是无状态的。这样用户就永远无法成功登录账号。
由此,引入了Cookie技术,Cookie 技术通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。Cookie 会根据从服务器端发送的响应报文内的一个叫做 Set-Cookie 的首部字段信息, 通知客户端保存 Cookie。 当下次客户端再往该服务器发送请求时, 客户端会自动在请求报文中加入 Cookie 值后发送出去。服务器端发现客户端发送过来的 Cookie 后, 会去检查究竟是从哪一个客户端发来的连接请求, 然后对比服务器上的记录, 最后得到之前的状态信息
加入了Cookie之后,上面的例子变成了:
在第一次收到http响应时会保存一个Cookie文件,此后访问服务端时,都会默认加上Cookie信息。
内存级别 / 文件级别
Cookie文件分为内存级别和文件级别,内存级别顾名思义就是保存在内存当中,当我们关机重启之后就不存在了,此时我们需要重新登录,而文件级别就是写入磁盘当中,关机重启之后依然存在
Cookie被盗问题
在有了以上认识后,我们会想到,如果别人盗取了我们的cookie文件怎么办,他就知道了我们的账号和密码了。
此时就可以引入SessID的概念了。
服务端在收到用户名和密码后,会用用户名和密码生成一个独一无二的SessionID,并把SessionID和用户名密码关联起来,然后将SessionID返回给用户,用户将来访问时只使用SessionID。
这种方法无法解决Cookie被盗的问题,但是相对来说比较安全,用户的用户名和密码无法被盗取。
我们可以做一个测试:
std::string status_line = "http/1.1 200 OK\n"; // 状态行
std::string response_header = "Content-Length: " + std::to_string(file.size()) + "\n"; // 响应报头
response_header += "Set-Cookie: name=zhangsan";
response_header += "Set-Cookie: possword=123456";
std::string blank = "\n"; // 空行
std::string response_text = file; // 响应正文
std::string response = status_line + response_header + blank + response_text; // 响应报文
构建的响应报头如上所示。
可以看到我们的网站的Cookie文件当中果然存在了用户名和密码。