「前言」文章内容大致是应用层协议的HTTP协议讲解。
「归属专栏」网络编程
「主页链接」个人主页
「笔者」枫叶先生(fy)
「枫叶先生有点文青病」「句子分享」
俗话说,开弓没有回头箭,唯有箭折、箭落、箭中靶子三种结果而已。
——江晓英《苏东坡:最是人间真情味》
HTTP(Hyper Text Transfer Protocol)
协议又叫做超文本传输协议,是一个的请求-响应协议,工作在应用层
虽然我们说,应用层协议可以我们自己定制,但实际上, 已经有极其优秀的工程师已经定义了一些现成的协议,应用层协议HTTP(超文本传输协议)就是其中之一,供我们直接参考使用。
平时我们俗称的 “网址” 其实就是说的URL
URL(Uniform Resource Lacator)
叫做统一资源定位符,也就是我们通常所说的网址。
(1)协议方案名
http://
中的http
表示的是协议名称,表示请求时需要使用的协议,我们日常上网常见到的协议有:http
和https
,我们要讲解的就是http协议
,https
协议称为安全数据传输协议,下个篇章谈。(2)登录信息
usr:pass
表示的是登录认证信息,包括登录用户的用户名和密码。现在绝大多数URL的这个字段都是被省略的(3)服务器地址
www.example.jp
表示的是服务器地址,也叫做域名。这个域名就是IP
地址,用于标识唯一的主机,这个域名会被解析成IP
地址,域名解析是由域名解析服务器完成的(4)服务器端口号
80
表示的是服务器端口号,http
协议的默认端口号是80
,https
协议默认端口号是443
。http
协议时不需要指明该协议对应的端口号(5)带层次的文件路径
/dir/index.htm
表示的是要访问的资源所在的路径/
是web的根目录,并不是Linux的根目录,web根目录可以是Linux下的任何一个目录http
协议就是从远端的服务器获取资源到本地的一种协议
我们在网络看到的一切东西,都是资源,比如文字、音频、图片、网页…等。这些资源(文件)一定在某个服务器上存放着。HTTP
协议可以传输多种文件资源的种类,所以叫做超文本传输协议,而不是叫做文本传输协议。可以传输多种文件资源的种类体现在超
字。
(6)查询字符串
uid=1
表示的是请求时提供的的参数,通过&
符号分隔开
(7)片段标识符
ch1
表示的是片段标识符,是对资源的部分补充
在URL里,像/
和?
等这样的字符,已经被url当做特殊意义理解了。因此这些字符不能随意出现。
比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义
转义的规则如下:
将需要转码的字符转为16进制
,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%
,编码成%XY
例如,我们在浏览器搜索东西时:
比如,我们搜索C++
,wd
后面全是我们的搜索参数(wd
是参数的名字),+
加号在URL当中属于特殊符号,而+
字符转为十六进制后的值就是0x2B
,因此一个+
就会被编码成一个%2B
注:汉字和特殊字符都要进行转换,这个过程成为URLencode
当服务器收到我们的请求是,会对特殊符号%xx
进行解码,这个过程称为URLdecode
。使用C++
进行编写服务器的时候,我们是需要做这个工作的(网上有源码,直接使用即可)
下面我们验证一下这个解码的过程,网上随便搜一个在线URL解码工具使用即可
HTTP是基于请求和响应的应用层服务,作为客户端,你可以向服务器发起request
,服务器收到这个request
后,会对这个request
进行分析,得出你想要访问什么资源,然后服务器再构建响应response
,完成这一次HTTP的请求。基于request&response
这样的工作方式,称之为cs
或bs
模式,c表示client
,s表示server
,b表示browser
其中浏览器便是http
协议的客户端,意味着我们使用http
协议是不需要编写客户端的
name:value
的形式按行陈列 + 结尾的[\r\n]Content-Length
来标识请求正文的长度注意:http是以特殊符号(\r\n)进行分割内容的
前三部分是一般是HTTP协议自带的,最后一部分请求正文可以没有(空字符串),请求打包好之后,直接交付给下一层:传输层,由传输层再进行处理
name:value
的形式按行陈列 + 结尾的[\r\n]Content-Length
属性来标识响应正文的长度注意:http是以特殊符号(\r\n)进行分割内容的
前三部分是一般是HTTP协议自带的,最后一部分响应正文可以没有(空字符串),请求打包好之后,直接交付给下一层:传输层,由传输层再进行处理
怎么保证在应用层完整读取一个http请求和响应??
\r\n
)while
循环读取完整的一行(以\r\n
进行分割),直到所有的请求报头或响应报头全部读取完成,读取到空行代表读取完成Content-Length
,这个字段用于标识响应正文或请求正文的长度Content-Length
解析,得到正文的长度,这样就可以保证读取正文完整,按解析出来的长度直接读取即可这样就保证了在应用层完整读取一个http请求和响应
http请求和响应是怎么做到序列化和反序列化的??
http
自己实现,通过使用特殊字符\r\n
来实现,第一行 + 请求/响应报头
只要安照特殊字符进行按行读取,即可得到整个字符串以上是对http
协议的宏观认识,下面编写代码认识http
协议。
下面编写一个简单的TCP服务器,这个服务器要做的就是把浏览器发来的HTTP请求进行打印即可
httpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "protocol.hpp"
static const int gbacklog = 5;
using func_t = std::function<bool(const httpRequest &req, httpResponse &resp)>;
// 错误类型枚举
enum
{
UAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
// 业务处理
void handlerHttp(int sockfd, func_t func)
{
char buffer[4096];
httpRequest req;
httpResponse resp;
size_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
req.inbuffer = buffer;
func(req, resp);
send(sockfd, resp.outbuffer.c_str(), resp.outbuffer.size(), 0);
}
}
class ThreadDate
{
public:
ThreadDate(int sockfd, func_t func) : _sockfd(sockfd), _func(func)
{}
public:
int _sockfd;
func_t _func;
};
class httpServer
{
public:
httpServer(const uint16_t &port) : _listensock(-1), _port(port)
{}
// 初始化服务器
void initServer()
{
// 1.创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock == -1)
{
std::cout << "create socket error" << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << _listensock << std::endl;
// 2.绑定端口
// 2.1 填充 sockaddr_in 结构体
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0
local.sin_family = AF_INET; // 未来通信采用的是网络通信
local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000
// 2.2 绑定
int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
if (n == -1)
{
std::cout << "bind socket error" << std::endl;
exit(BIND_ERR);
}
std::cout << "bind socket success" << std::endl;
// 3. 把_listensock套接字设置为监听状态
if (listen(_listensock, gbacklog) == -1)
{
std::cout << "listen socket error" << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen socket success" << std::endl;
}
// 启动服务器
void start(func_t func)
{
for (;;)
{
// 4. 获取新链接,accept从_listensock套接字里面获取新链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 这里的sockfd才是真正为客户端请求服务
int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
{
std::cout << "accept error, next!" << std::endl;
continue;
}
std::cout << "accept a new line success, sockfd: " << sockfd << std::endl;
// 5. 为sockfd提供服务,即为客户端提供服务
// 多线程版
pthread_t tid;
ThreadDate *td = new ThreadDate(sockfd, func);
pthread_create(&tid, nullptr, threadRoutine, td);
}
}
static void *threadRoutine(void *args)
{
pthread_detach(pthread_self()); // 线程分离
ThreadDate *td = static_cast<ThreadDate *>(args);
handlerHttp(td->_sockfd, td->_func); // 业务处理
close(td->_sockfd); // 必须关闭,由新线程关闭
delete td;
return nullptr;
}
~httpServer()
{}
private:
int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来
uint16_t _port; // 端口号
};
httpServer.cc
#include "httpServer.hpp"
#include
// 使用手册
// ./httpServer port
static void Uage(std::string proc)
{
std::cout << "\nUage:\n\t" << proc << " local_port\n\n";
}
bool get(const httpRequest &req, httpResponse &resp)
{
std::cout << "----------------------http start----------------------" << std::endl;
std::cout << req.inbuffer;
std::cout << "----------------------http end ----------------------" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Uage(argv[0]);
exit(UAGE_ERR);
}
uint16_t port = atoi(argv[1]); // string to int
std::unique_ptr<httpServer> tsvr(new httpServer(port));
tsvr->initServer(); // 初始化服务器
tsvr->start(get); // 启动服务器
return 0;
}
protocol.hpp
#pragma once
#include
#include
#include
class httpRequest
{
public:
std::string inbuffer;
};
class httpResponse
{
public:
std::string outbuffer;
};
运行服务器程序后,然后用浏览器进行访问,此时我们的服务器就会收到浏览器发来的HTTP请求并打印出来
由于代码什么都没有,只会显示以下信息
服务器就会收到浏览器发来的HTTP请求并打印出来(虽然只访问了一次,但是会收到多个HTTP请求,浏览器的行为)
GET / HTTP/1.1
Host: 119.3.185.15: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/114.0.0.0 Safari/537.36 Edg/114.0.1823.67
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,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 / HTTP/1.1
,GET
是请求方法,是浏览器默认的,URL为\
,这是因为我们没有具体的请求,浏览器就会默认访问的是\
(web根目录),HTTP/1.1
是HTTP的版本号剩下的全是请求报头,请求报头当中全部都是以name: value
形式按行陈列的各种请求属性
还会打印一个空行,由于请求正文没有,默认为空字符串,就没有显示的打印信息
客户端主机版本信息:
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.67
User-Agent
是显示发起请求客户端主机的版本信息
比如我们搜索东西下载的时候,它会给我们默认显示符合我们自己操作系统的下载,他怎么知道我们要下的是电脑版??
原因就是,我们在发起请求的时候,请求就已经携带了我们操作系统的版本信息
剩下的这些,就是告诉服务端,我客户端目前支持什么,比如编码格式、什么样的文本等
有些到后面再谈
如何将HTTP报头与有效载荷进行分离?
HTTP为什么要交互版本?
简单加一点代码,让我们观察HTTP响应
bool get(const httpRequest &req, httpResponse &resp)
{
std::cout << "----------------------http request start----------------------" << std::endl;
std::cout << req.inbuffer;
std::cout << "+++++++++++++++++++++++++++++" << std::endl;
std::cout << "request method: " << req.method << std::endl;
std::cout << "request url: " << req.url << std::endl;
std::cout << "request httpversion: " << req.httpversion << std::endl;
std::cout << "request path: " << req.path << std::endl;
std::cout << "request file suffix: " << req.suffix << std::endl;
std::cout << "request body size: " << req.size << "字节" << std::endl;
std::cout << "----------------------http request end ----------------------" << std::endl;
std::cout << "----------------------http response start ----------------------" << std::endl;
std::string respline = "HTTP/1.1 200 OK\r\n"; // 响应状态行
std::string respheader = Util::suffixToDesc(req.suffix);
std::string respblank = "\r\n"; // 响应空行
std::string respbody; // 响应正文
respbody.resize(req.size);
if (!Util::readFile(req.path, (char *)respbody.c_str(), req.size)) // 访问资源不存在,打开404html
{
struct stat st;
stat(html_404.c_str(), &st);
respbody.resize(st.st_size);
Util::readFile(html_404, (char *)respbody.c_str(), st.st_size); // 一定成功
}
resp.outbuffer = respline;
respheader += "Content-Length: ";
respheader += std::to_string(respbody.size());
respheader += respblank;
resp.outbuffer += respheader;
resp.outbuffer += respblank;
std::cout << resp.outbuffer;
resp.outbuffer += respbody;
std::cout << "----------------------http response end ----------------------" << std::endl;
return true;
}
代码太多就不贴出来了,gitee链接:链接
运行结果,服务器响应回来的(当浏览器访问我们的服务器时,服务器会将这个index.html
文件响应给浏览器,默认index.html
文件为访问网站的首页)
自己打印出的部分响应信息
注:由于只是作为示例,在构建HTTP响应时,在响应报头当中只添加了两个属性信息,实际HTTP响应报头当中的属性信息还有很多
最常用的就是GET方法和POST方法
在进行前后端数据交互时,本质是前端要通过form
表单提交的,浏览器会自动将表单的内容转换成GET/POST
请求
例如,前端表单提交页面
action="/a/test.py"
意思是表单提交到指定的路径文件下,method="GET"
意思是http的访问方法是GET
启动服务器,浏览器进行访问
进行提交内容,比如张三,123123
因为访问的页面/a/test.py
不存在,显示到404
页面(自己设定的)
查看服务端打印的请求信息
GET
方法提交参数的时候,会把参数提交拼接到URL后面
/a/test.py?
前面是我们所要请求的资源,后面xxxname=%E5%BC%A0%E4%B8%89&yyypwd=123123
是表单提交的信息,在浏览器网址栏也会看到我们提交的内容
下面试一下POST
方法,修改HTML
浏览器访问
提交表单,在浏览器网址栏不会看到我们提交的内容,但是可以看到我们所访问的资源
查看服务端打印的请求信息
POST
方法提交表单信息,提交的参数放在http请求的正文里面
在浏览器网址栏不会看到我们提交的内容,但是可以看到我们所访问的资源
总结:
GET/POST
http请求方法的区别
GET
方法提交参数是通过URL传递参数,例如:http://ip:port/xxx/yyy?name=value&name2=value2...
POST
方法提交参数是通过http请求正文提交参数POST
方法通过请求正文提交参数,用户一般看不到,私密性比较好GET
方法提交参数是通过URL传递参数,谁都可以看到GET
方法通过URL传参,注定参数不能太大,而POST
方法通过正文传参,正文可以很大注意:私密性 != 安全性,http安全性都不好,都可以被别人直接抓到
HTTP的状态码如下:
注:1xx代表以1开头的状态码,状态码有三位,例如404
最常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway)
下面谈一下Redirection(重定向状态码)
重定向就是通过各种方法将各种网络请求重新定个方向转到其它位置,此时这个服务器相当于提供了一个引路的服务
重定向是客户端完成的,是服务端告诉客客户端
重定向又可分为临时重定向和永久重定向,其中状态码301表示的就是永久重定向,而状态码302和307表示的是临时重定向的
Moved Permanently,永久重定向
Temporary Redirect(临时重定向)
更多重定向的解释,文章链接:重定向
下面演示一下临时重定向
将HTTP响应当中的状态码改为307,然后跟上对应的状态码描述,此外,还需要在HTTP响应报头当中添加Location字段,这个Location后面跟的就是你需要重定向到的网页,比如我们这里将其设置为我的CSDN的首页
此时当浏览器访问我们的服务器时,就会立马跳转到CSDN的首页
服务器响应打印信息
HTTP常见的Header如下:
Content-Type
:数据类型(text/html等)Content-Length
:Body的长度Host
:客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;User-Agent
:声明用户的操作系统和浏览器版本信息;Referer
:当前页面是从哪个页面跳转过来的Location
:搭配3xx状态码使用, 告诉客户端接下来要去哪里访问Cookie
:用于在客户端存储少量信息. 通常用于实现会话(session)的功能Host
Host
字段表明了客户端要访问的服务的IP和端口,比如当浏览器访问我们的服务器时,浏览器发来的HTTP请求当中的Host字段填的就是我们的IP和端口
User-Agent
这个前面已经说过了,User-Agent
代表的是客户端对应的操作系统和浏览器的版本信息
Referer
Referer
代表的是你当前是从哪一个页面跳转过来的。Referer
记录上一个页面的好处一方面是方便回退,另一方面可以知道我们当前页面与上一个页面之间的相关性。
Keep-Alive(长连接)
Keep-Alive
,也称为长连接,是一种在HTTP协议中使用的技术,用于在客户端和服务器之间保持持久的连接,以减少每次请求的延迟和资源消耗在传统的HTTP协议中,每次客户端发送请求后,服务器会立即返回响应并关闭连接。这样的连接方式称为短连接。而长连接则是在客户端和服务器之间建立一次连接后,可以通过该连接发送多个请求和接收多个响应
长连接的优点包括:
注意的是:长连接并不是永久的,服务器和客户端都可以主动关闭连接
HTTP请求或响应报头当中的Connect
字段对应的值是Keep-Alive
,就代表支持长连接。
接下来详细谈一下Cookie和Session
HTTP实际上是一种无状态协议,HTTP的每次请求/响应之间是没有任何关系的,但是你在使用浏览器的时候发现并不是这样的。
例如,我们在登录一个网站时,比如bilibili,登录一次之后,这个登录状态可以保持很久,把bilibili的网站关掉,再重新打开,我们发现账号依旧是登录状态,并不用重新进行登录账号,即使把浏览器关掉,也是如此
这就是通过
cookie
和session
实现的,这称为会话保持
注意:会话保持严格来说不是http天然具备的,是后面使用之后发现是需要会话保持的。
http协议是无状态的,但是用户需要。用户进行网页操作的时候,查看新网页时必须的,如果发生页面跳转,那么新的页面就无法识别是哪一个用户了,又需要进行重新登录,这显然是不合适的
所以,为了用户一经登录,可以在整个网站按照自己的身份进行随便访问,这就需要进行会话保持了
会话保持(老方法)
cookie
cookie
Cookie
是一种存储在用户浏览器中的小型文本文件,用于存储用户的身份认证信息、个性化设置等。当用户访问网站时,服务器会将一些信息存储在Cookie
中,并在以后的请求中将这些Cookie
发送给服务器cookie
保存分cookie文件
保存和cookie内存
保存两种保存方式这个cookie
在浏览器里面可以进行管理,把这些cookie
全部删掉,所有的网站就需要重新进行登录
在网站里,登录好之后,我们也可以查看该网站的cookie
进行测试,把该网站的cookie
删除,删除之后,用户就不是登录状态了,需要进行重新登录
使用
cookie
存在的问题
正常情况下是没有问题的
用户的不安全操作中了病毒,蠕虫、木马之类,用户自己的cookie
就被泄露的
cookie
被不怀好意的人拿到之后,黑客从自己的浏览器直接就可以访问服务器,服务器就会误认为是用户在访问服务端(对社会危害大)
解决方案session
session
session
是一种服务器端的存储技术,用于存储用户的会话信息。Session ID
,并将该ID存储在Cookie
中发送给浏览器。浏览器在后续的请求中会自动将该Session ID
发送给服务器。服务器根据Session ID
来查找对应的会话信息,并将用户的数据存储在服务器端session
是存储在服务端的,每个用户都有一个session文件
,session ID
在这个在服务器是唯一(一个字符串)sessionID
即可,即把sessionID
放入cookie
中当我们第一次登录某个网站输入账号和密码后,服务器认证成功后还会服务端生成一个对应的SessionID
,这个SessionID
与用户信息是不相关的
此时当认证通过后服务端在对浏览器进行HTTP响应时,就会将这个生成的SessionID值响应给浏览器。浏览器收到响应后会自动提取出Session ID
的值,将其保存在浏览器的cookie
文件当中。后续访问该服务器时,对应的HTTP请求当中就会自动携带上这个Session ID
。
session ID
失效,只有有密码的那个人才可以登录,登录成功再次形成session ID
,这在一定程度上缓解了session ID
被盗的问题(无法根治)安全是相对的
下面进行验证,客户端会携带cookie信息
Set-Cookie
字段,那么当浏览器再次访问服务器时就会携带上这个cookie
信息j简单修改一下上面的代码,代码过多就不粘贴了,链接:代码
在服务器的响应报头当中添加上一个Set-Cookie
字段,看看浏览器第二次发起HTTP请求时是否会带上这个Set-Cookie
字段
运行服务器后,用浏览器访问我们的服务器,cookie
的值就是我们设置的1234567asdf
,此时浏览器当中就写入了这样的一个cookie
客户端第二次请求就已经携带cookie信息
往后,每次http请求,都会自动携带曾经设置好的所有cookie,帮助服务器进行鉴权行为,这就是http的会话保持的功能
工具推荐:
postman:HTTP调试工具,模拟浏览器的行为
fiddler:抓包工具,HTTP工具
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.7.11
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。