前言: 在计算机网络基础(一)中,我们 大体的认识了一下 网络协议栈,应用层,传输层,网络层,数据链路层。都是讲的概念,不够细致。在 本文,要详细的介绍 应用层和传输层,应用层以 HTTP协议为例 进行讲解,传输层以UDP协议和TCP协议 这两个常见的协议 进行讲解。最后 会提供 一些分析 网络问题的工具 和 方法。
1. 应用层
应用层,是我们用户可以控制的一层,程序员解决问题都是 在应用层 编写的程序。传输层和网络层是操作系统进行管理的。应用层只能通过接口来进行一些网络操作,这是种封装嘛。
1.1 定制应用层协议
协议是什么?它是一种约定
。为什么要有协议?因为 它提高了 网络通信的效率。
在上一篇文章,网络编程套接字 中,它们之间简单的网络通信 都是 直接发的一些 字符串。但是如果 要求 发结构化的数据呢?
就比如:
struct usr
{
string name;
string message;
string time;
}
假如实现 简单的 聊天,需要包括 聊天对象的名称,聊天信息,发送时间。我们可不可以直接发送一个结构体呢?
答案是 不可以。字符串是便于网络传输的,直接发送结构体是不可以的。
这就需要 俩个概念 序列化
反序列化
:
- 序列化: 将结构化数据 整合 为 一个长的字符串 ,这是序列化的过程。
- 反序列化:将 字符串 还原 成结构体数据的过程 ,这是反序列化的过程。
图解:
请求:
响应:
大家可能有疑问?把结构体化数据转换为一个字符串就是序列化,再将一个字符串还原为一个结构体数据就是反序列化。但是 具体 是怎么做到的?所谓定制协议嘛,是靠我们用户自己来完成这 序列化 和 反序列化。
嗯,定制协议就是件复杂的事情,其实我们一般不考虑。因为现在有现成的,很多比如:HTTP,HTTPS,DNS,FTP等。
当然 你也可以不用 这些现成的协议,自己来定制。
接下来,我们就自己动手定制一个协议,用于 网络版计算器 实现。
总结:
应用层协议要完成基本的俩个任务:
- 网络通信(调用系统接口)
- 序列化和反序列化(处理发出数据,接收数据)
其次,就是 协议内部 的细节。
1.2 网络版计算器实现
首先 想一下 结构化数据是 怎样的?也就是 协议的请求中 的数据:
struct request
{
int x;
int y;
char op;
};
那么计算器 得出结果后,响应的格式呢?
struct response
{
int result;
int code;
};
result 是计算结果,code 是计算结果的标志位,如果结果正常 就设为0,如果不正常 就设为 -1。
有了这些我们就开始序列化和反序列化的编写,请求一套序列化,反序列化;响应也对应一套。和上面图解不一样,因为 图解中的请求和响应是一样的的结构化数据,但是在这里 请求和响应的结构化数据不一样,所以需要两套。
这里说的有点不清楚具体看如下代码:
、、、、protocol.hpp
#pragma once
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
using namespace std;
typedef struct request
{
int x;
int y;
char op;
} request_t;
typedef struct response
{
int result;
int code;
} response_t;
// 1.请求序列化和反序列化
// 1.1 请求序列化 (request->string)
std::string S_request(request_t &i)
{
Json::Value root;
root["date_x"] = i.x;
root["date_y"] = i.y;
root["op"] = i.op;
Json::FastWriter writer;
std::string ret_s = writer.write(root);
return ret_s;
}
// 1.2 请求反序列化
void D_request(const string &i, request_t &o)
{
Json::Reader reader;
Json::Value root;
reader.parse(i, root);
o.op = (char)root["op"].asInt();
o.x = root["date_x"].asInt();
o.y = root["date_y"].asInt();
}
// 2. 响应的序列化和反序列化
// 2.1 响应的序列化
std::string S_response(const response_t &i)
{
Json::FastWriter writer;
Json::Value root;
root["result"] = i.result;
root["code"] = i.code;
std::string ret_s = writer.write(root);
return ret_s;
}
// 2.2 响应的反序列化
void D_response(const string &i, response_t &o)
{
Json::Reader reader;
Json::Value root;
reader.parse(i, root);
o.code = root["code"].asInt();
o.result = root["result"].asInt();
}
这就是 请求的序列化和反序列化 ;响应的序列化和反序列化;
我利用的是json库 ,进行的数据格式转换(讲结构体转换为字符串)。至于json库,这里不多言,只需要知道它能够完成 数据格式转换即可。
再给出一个头文件,用于创建套接字,直接封装成一个类,有利于我们操作:
,,, sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
class Sock
{
public:
// 创建套接字
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket erro" << endl;
exit(2);
}
return sock;
}
// 绑定端口号
static void Bind(int sock, uint16_t port)
{
struct sockaddr_in si;
memset(&si, 0, sizeof(si));
si.sin_family = AF_INET;
si.sin_port = htons(port);
si.sin_addr.s_addr = INADDR_ANY;
if (bind(sock, (const struct sockaddr *)&si, sizeof(si)) < 0)
{
cerr << "bind erro" << endl;
exit(3);
}
}
// 设置为监听状态
static void Listen(int sock)
{
if (listen(sock, 3) < 0)
{
cerr << "listen erro" << endl;
exit(4);
}
}
// 链接
static int Accept(int sock)
{
struct sockaddr_in i;
socklen_t len = sizeof(i);
int j = accept(sock, (struct sockaddr *)&i, &len);
if (j < 0)
{
cerr << "accept erro" << endl;
return -1;
}
else
{
return j;
}
}
// 请求链接
static void Connect(int sock, std::string ip, uint16_t port)
{
struct sockaddr_in i;
memset(&i, 0, sizeof(i));
i.sin_family = AF_INET;
i.sin_port = htons(port);
i.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(i);
if (connect(sock, (struct sockaddr *)&i, len)==0)
{
cout << "connect success" << endl;
}
else
{
cerr << "connect faild" << endl;
exit(5);
}
}
};
然后是客户端 和 服务端 的代码:
客户端:
#include "protocol.hpp"
#include "sock.hpp"
void usage()
{
cout << "please use: ./client server_ip server_port" << endl;
}
int main(int argv, char *argc[])
{
if (argv != 3)
{
usage();
exit(1);
}
int sock = Sock::Socket();
Sock::Connect(sock,argc[1],atoi(argc[2]));
request_t i;
memset(&i,0,sizeof(i));
cout << "please Enter date_x" << endl;
cin >> i.x;
cout << "please Enter date_y" << endl;
cin >> i.y;
cout << "please Enter op" << endl;
cin >> i.op;
std::string josn_string = S_request(i);
ssize_t n = write(sock,josn_string.c_str(),josn_string.size());
char buffer[1024];
n = read(sock,&buffer,sizeof(buffer)-1);
if(n>0)
{
response_t out;
buffer[n]=0;
std::string s = buffer;
cout<<s<<endl;
D_response(s,out);
cout<<"确认码:"<<out.code<<endl;
cout<<"结果:"<<out.result<<endl;
}
return 0;
}
服务端:
#include <thread>
#include "protocol.hpp"
#include "sock.hpp"
void usage()
{
cout << "please use ./serve port" << endl;
}
void* run(void *s)
{
int sock = *(int*)s;
delete (int*)s;
pthread_detach(pthread_self());
/// 读取请求
request_t out;
char buffer[1024];
ssize_t j = read(sock, buffer, sizeof(buffer) - 1);
if (j > 0)
{
buffer[j] = 0;
string ss = buffer;
D_request(ss, out);
/// 构建响应
response_t n = {0, 0};
switch (out.op)
{
case '+':
n.result = out.x + out.y;
break;
case '-':
n.result = out.x - out.y;
break;
case '*':
n.result = out.x * out.y;
break;
case '/':
if (out.y != 0)
{
n.result = out.x / out.y;
}
else
{
n.code = -1;
}
break;
case '%':
if (out.y != 0)
{
n.result = out.x % out.y;
}
else
{
n.code = -2;
}
break;
default:
n.code = -3;
break;
}
cout<<"开始服务"<<endl;
cout<< out.x<<" "<<out.op<<" "<<out.y<<"= ?" <<endl;
string o = S_response(n);
/// 发送响应
write(sock,o.c_str(),o.size());
cout<<"服务结束"<<o<<endl;
close(sock);
}
}
int main(int argv, char *argc[])
{
if (argv != 2)
{
usage();
exit(1);
}
uint16_t port = atoi(argc[1]);
int listen_fd = Sock::Socket();
Sock::Bind(listen_fd, port);
Sock::Listen(listen_fd);
while (true)
{
int sock = Sock::Accept(listen_fd);
if (sock >= 0)
{
cout << "get a new usr" << endl;
pthread_t i;
int *p = new int(sock);
pthread_create(&i, nullptr, run,p);
}
}
return 0;
}
makefile:
.PHONY:all
all:client serve
client:client.cc
g++ -o $@ $^ -std=c++11 -ljsoncpp
serve:serve.cc
g++ -o $@ $^ -std=c++11 -ljsoncpp -lpthread
.PHONY:clean
clean:
rm -f client serve
运行结果:
这就是网络版的计算机服务 简易实现。
1.3 应用层细分
应用层 就是 向应用程序提供网络接口,直接向用户提供服务。如果要细分它的话,可以分为三层:
- 应用层:特定的协议
- 表示层:数据格式的转换
- 会话层:通信的管理
我们可以想一想 上面的网络计算机 ,它的应用层其实就是 我们自己定义的请求,响应;
它的表示层 就是 我们利用json库 进行的数据格式转换,序列化:将结构体转换为字符串,反序列化:将字符串转换为结构体;它的会话层,也就是 我们写的通信代码。
其实 应用层的那些协议,最起码都支持这三个层面的功能,只不过是根据不同情况,来选定适用的 协议罢了。
1.4 HTTP协议
这个协议我们着重讲,它是超文本传输协议。但是我们基本上 用到的是 https,它其实就是加密了一下而已,我们慢慢的讲。
1.4.1 认识网址(URL)
https://cloud.tencent.com/developer/article/1853542
比如上面这个网址,它可以分为那几部分呢?
- 第一个部分:协议方案名
- 第二个部分:服务器地址
- 第三个部分:文件路径
我们回想一下 IP+端口号 就可以明确 任意一台主机的一个进程。那么 我们访问网络,其实就是在像某一台服务器请求资源,如何确定这个资源呢?答案是 文件路径,IP+文件路径就可以确定 任意一台主机上的一个资源。
从上面的网址,可以看到它用的是什么协议,服务器的地址,以及资源的路径。
需要注意的是 在网址中,有些字符需要被转义:
https://cn.bing.com/search?q=2%2B%2B&qs=n&form=QBRE&sp=-1&pq=2%2B%2B&sc=0-3&sk=&cvid=DBCA7BAA3ACF4F15A22FC87CF43250DF&ghsh=0&ghacc=0&ghpl=
可以看到,这个网址非常奇怪,文件地址 怎么可能这么乱呢? 答案是 肯定有的字符被转义了。
比如上面的 2%2B%2B 是啥?
我们可以用一下解码工具:解码yyds
答案揭晓:
其实我就是在浏览器上搜索了一下:2++而已
简单说明:%XY
这种格式 一般都是 被转义了。
1.4.2 HTTP协议简易了解
Http是应用层协议,所以 它也得有 请求和响应。
请求和响应都是 以行为单位的,一般是以四部分组成,有时也会是三部分:
具体如图:
请求:
响应:
干巴巴两张图 说实话 也不够详细,接下来,写一个简单的 HTTP服务器。
1.4.3 简易版HTTP服务器
首先说明一下,这个服务器的功能,就是 我在浏览器上 访问,然后 在页面上打印 hollow ly。我们顺便看一下,请求的报文:
,,, http.cpp
#include "sock.hpp"
#include <iostream>
#include<string>
using namespace std;
void usag()
{
cout << "please use ./Http port" << endl;
}
void *run(void *p)
{
int sock = *(int *)p;
delete (int *)p;
pthread_detach(pthread_self());
char buffer[1024];
ssize_t n = recv(sock, buffer, sizeof(buffer), 0);
if (n > 0)
{
// 打印请求
buffer[n] = 0;
cout << buffer << endl;
// 编辑响应
char buf[1024] = {0};
const char* hello = "<h1>hellow ly</h1>";
sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);
write(sock, buf, strlen(buf));
}
close(sock);
return nullptr;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
usag();
exit(1);
}
// 创建套接字
uint16_t port = atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
while (true)
{
int sock = Sock::Accept(listen_sock);
if (sock >= 0)
{
pthread_t i;
int *p = new int(sock);
pthread_create(&i, nullptr, run, p);
}
}
return 0;
}
运行时:
看接收报文:
看这个请求报文:
- 第一行:方法 + 访问资源路径 + http版本 (GET,/ ,HTTP/1.1)
- 第二行以及到末尾:这些都是报头,里面全是请求报文的属性。
- 末尾是有空行的
- 很明显,这里并没有 请求正文
1.4.4 HTTP请求报文详解
第一行,最开始就是 http方法,常见的有GET,POST等。这个一会讲。然后是访问资源路径,上图显示的是\
,大家要注意,这个路径 并不是 服务器的根路径,它是web根目录,一般情况下,都是访问web根目录下的某个资源,如果是\
,那么默认请求的就是首页资源。最后就是http版本,这没啥讲的。
至于报文的属性先不研究。
空行是有说法的,也就是报文的第三部分,为啥要有空行,这个问题大家想过没有,空行的存在,它可以使得,正文与之前的东东 分割开,所以说 读到空行时,说明之前的报头都读取完了,开始 读取正文。
带正文的报文,下面会展示的,关键有个问题,如何确定:读正文 不越界?有没有可能读取正文时,读越界了? 这里 就需要 报头中属性里 有 content-length ,它标注着 正文的长度。
总结一下: content-length 和 空行 ,保证了读取到完整正文。
那么接下来我们的任务有三个:
- 带有正文的报文请求
- 验证 \ ,以及访问 web根目录的其他资源
- 学习GET,POST方法,理解其中的区别
带有正文的请求,也就是说 在请求网络资源时,用户要提交数据,怎么提交数据呢?这需要些前端的东西,简单的那种,我们在 访问网页时,只要登录 一下信息,那就是 提交数据。
我们先设置一下登录页面,这是前端的内容:
在当前文件下,创建一个目录wwwroot,它就是web根目录,也就是你供别人访问的网上目录;在目录下,你可以放置自己的网络资源。必须要有首页,index.html。
、、、 index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>首页</h1>
<h2>登录信息</h2>
<form action="/" method="get">
姓名: <input type="text" name="name"><br/>
密码: <input type="password" name="passwd"><br/>
<input type="submit" value="登陆">
</form>
</body>
</html>
注意我默认使用的方法是 GET:
其实网页都是 这样的 ,不过我们 弄得比较简单。可以给大家看一下:
右键页面 会有一个查看页面源代码,或者可以ctrl + u。
在web根目录下,还可以添加 你想要的页面,之类的资源,比如我在根目录下又创建了一个ly目录,里面方了一个 页面资源:
对吧,这方便我们后续验证。
然后就是 编写HTTP服务器代码:
#include "sock.hpp"
#include <iostream>
#include <fstream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
using namespace std;
void usag()
{
cout << "please use ./Http port" << endl;
}
void *run(void *p)
{
int sock = *(int *)p;
delete (int *)p;
pthread_detach(pthread_self());
char buffer[1024];
ssize_t n = recv(sock, buffer, sizeof(buffer), 0);
if (n > 0)
{
// 打印请求
buffer[n] = 0;
cout << buffer << endl;
std::string s = "./wwwroot/index.html";
std::ifstream ii(s);
if (!ii.is_open())
{
// 文件打开失败的响应
std::string http_response = "http/1.0 404 NOT FOUND\n";
// 正文部分的数据类型
http_response += "Content-Type: text/html; charset=utf8\n";
http_response += "\n";
http_response += "<html><p>你访问的资源不存在</p></html>";
send(sock, http_response.c_str(), http_response.size(), 0);
}
else
{
// 文件打开成功的响应
struct stat ww;
stat(s.c_str(), &ww);
std::string http_response = "http/1.0 200 ok\n";
// 正文部分的数据类型
http_response += "Content-Type: text/html; charset=utf8\n";
http_response += "Content-Length: ";
http_response += std::to_string(ww.st_size);
http_response += "\n";
http_response += "\n";
// 将文件中的内容,读取到line中
std::string content;
std::string line;
while (std::getline(ii, line))
{
content += line;
}
http_response += content;
ii.close();
send(sock, http_response.c_str(), http_response.size(), 0);
}
close(sock);
return nullptr;
}
close(sock);
return nullptr;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
usag();
exit(1);
}
// 创建套接字
uint16_t port = atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
while (true)
{
int sock = Sock::Accept(listen_sock);
if (sock >= 0)
{
pthread_t i;
int *p = new int(sock);
pthread_create(&i, nullptr, run, p);
}
}
return 0;
}
这里面有不少的细节,大家好好研究:
- ifstream ii 是对打开的文件 进行 IO操作,把登录信息 输入到 打开的首页里,用它很方便。
- 想要获取文件的长度,可以利用结构体 stat,它可以很方便的获取到文件的属性。
接下来就是 实践:
- 启动服务器
- 查看网页的响应
- 输入登录信息,查看请求报文
提交信息后,url会显示 信息:
惊奇的发现,正文里面竟然没有用户提交的信息,但是用户提交的信息 出现在了 网址信息里面(url)。这是因为 我用的请求HTTP的方法是 GET
。
那么如果我将请求方法变成 POST呢?
就是将首页代码中的 method 方法变为 “POST”:
我们再来重启服务,再次提交用户信息:
url处没有信息显示:
来查看请求报文:
发现了,这次提交的信息 是携带在正文中的。
- 验证访问 web根目录下的其他资源
我们其实只需要改变一下,访问路径就好了:
改变为:
好了 ,再次运行:
- 谈一谈 :GET和POST的区别
- GET:最常用的获取HTTP服务的方法,但是提交参数是通过 url,这就有些不保险。所以需要提交参数 一般不用GET,但如果 提交的参数 无关紧要,那么是 可以用GET的。
- POST:主要用于需要提交参数 的HTTP服务,它提交的参数 是通过正文的,所以私密性很好。
- 但需要注意:通过url提交参数是有大小限制的,但是通过正文提交参数是没有大小限制的。
- HTTP请求方法表:
1.4.5 响应报文
查看响应报文,可以使用 curl -i
+ IP+port 指令:
其实这个响应报文是我们交给用户看的,它也是我们自己填写的:
- 第一部分:状态行,先得是HTTP版本,状态码,状态码描述
- 第二部分:报文属性
- 第三部分:空行
- 第四部分:正文
我们着重讲讲 HTTP的状态码和常见的报文属性(header)。
- HTTP状态码
1XX:
2XX:
3XX:
4XX:
5XX:
总结一下:这些状态码不需要我们去背,太多了。而且对于状态码的支持,还得看看浏览器支持不支持,因为浏览器的种类很多,不见得 可以 识别某些状态码。
但是 作为常识我们也可以了解 常见的状态码。
比如:程序员比较愿意看到2XX,这就表示服务器这边没啥大毛病;程序员不想看到5XX,出现5XX说明 服务器有问题;用户呢,用户最讨厌4XX,想必大家也遇见过403,404之类的,真的很烦,意思就是 客户端请求出问题。
1XX没咋见过。
3XX得讲讲:3XX是重定向,有临时重定向,永久重定向。
- 临时重定向:302 307
- 永久重定向:301
临时重定向就是原网址不变,当访问一些资源时,自动跳转到另一个网址。这是很常见的,比如我在饿了么下单,但是要用微信支付,毫无疑问,会发生跳转 (临时重定向),跳转到微信进行支付,支付完成,再跳回饿了么。
永久重定向这个情况就是,原来的网址不使用了,你进入旧的网址,它会自动跳转到新的网址,并且会修改 你的浏览器中的相关此网址的信息,比如你的收藏夹里收藏了这个旧的网址,发生永久重定向后,收藏夹的网址也会变为新的网址。这就是网址搬迁或者变换域名的情况。
- 常见的header
- Content-Type: 数据类型(text/html等)
- Content-Length: Body的长度
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能
Content-Type,Content-Length 在上面都已经使用过了,比较简单。像Host,user-Agent,referer ,location 都简单理解一下。主要讲一下 这个Cookie,这个不好理解。
Cookie 它可以保存 用户的一些信息,方便 我们去访问一些网页。比如 我刚登录了 4399小游戏这个网页,立马退出。然后再次点进去,发现 不需要二次登录了。甚至 很长时间 它都保存着登录信息,这是什么操作? Cookie
,它保存了 登录信息。
给大家看一下:
点击一下这个锁,
查看Cookie信息:
如果把cookie信息删除掉,那么 下次肯定需要二次登录。
那么 可不可以 我们也来设置cookie信息呢?当然可以,而且很简单。cookie 无非就是HTTP的响应的一部分,我们是可以很轻松的设置的:
只需要在响应中 加上 下面的字符串即可,大家可以观察一下,响应属性的填写 都是 Key-Value的形式:
比如:Set-Cookie: id=ly ,Key是Set-Cookie,Value是 id=ly
http_response += "Set-Cookie: id=ly\n";
http_response += "Set-Cookie: password=666\n";
我们来验证一下:
再来查看响应报文:
1.4.6 HTTP的一些安全问题
- GET将用户提交数据,表现在rul上,这不太安全
- cookie,cookie最好不要长期保存在浏览器上,cookie如果保存的是用户的密码之类信息,这就非常糟糕。
cookie和session(会话):
服务器为了保存用户状态而创建的一个特殊的对象就是 session。
当浏览器第一次访问服务器时,服务器创建一个session对象(该
对象有一个唯一的id,一般称之为sessionId),服务器会将sessionId
以cookie的方式发送给浏览器。
当浏览器再次访问服务器时,会将cookie发送过来,服务器依据
cookie就可以找到对应的session对象。
这就是 能够免认证登录的原理。
为什么长时间不登录,再次登录 需要重新输入 密码之类的?
因为session被销毁了,超时销毁,如果浏览器长时间不访问那个服务器,就会导致 服务器将器session销毁,再次登录,还会新建立一个 session。
其实cookie和session的存在,是为了提高用户的体验,但是 难免会带来一些安全隐患。所以网上冲浪的少年,注意自己的浏览器中cookie信息的泄露。
cookie的本质:
cookie它就是一个文件,里面保存着 用户的信息。一个网站中 有cookie存在,那么发起HTTP请求时请求报文就会携带cookie信息,与服务器中的session信息做比较,如果对上暗号了,那么 就免密登录。
1.5 HTTPS协议
HTTP是存在安全隐患的,它是直接明文 发送接收 报文。非常容易被人,窃取。所以HTTPS协议出现了,它其实就是 加密后的HTTP。
想要理解HTTPS协议其实比较困难,最起码得懂以下几点:
- 了解几个基本术语(HTTPS、SSL、TLS)的含义
- 了解 HTTP 和 TCP 的关系(尤其是“短连接”VS“长连接”)
- 了解加密算法的概念(尤其是“对称加密与非对称加密”的区别)
- 了解 CA 证书的用途
- TCP通信协议的几次握手以及挥手
1,2,3,4 这几点,我们先讲。至于5点,我们讲传输层时 再讲。
1.5.1 HTTPS和SSL/TLS
图解:
SSL/TLS 就是对HTTP的数据进行加密或者解密。防止他人读取和修改任何传输信息,包括个人资料。两个系统可能是指服务器和客户端(例如,浏览器和购物网站),或两个服务器之间(例如,含个人身份信息或工资单信息的应用程序)。
TLS是由SSL发展而来,事实上我们现在用的都是TLS,但因为历史上习惯了SSL这个称呼。
知晓这些就足够了,当作背景知识了解。
1.5.2 短链接和长链接
HTTP 1.0 版本默认用的就是短链接,HTTP 1.1 版本默认用的是长链接。
- 短链接就是 每次获取资源 都需要重新建立链接:
步骤:建立链接——数据传输——关闭链接 / 建立链接——数据传输——关闭链接
- 长链接就是 链接建立好后 保持链接,持续获取资源:
步骤:建立链接——数据传输……(保持链接)……数据传输——关闭链接
其实 短链接和长链接 是传输层的事,TCP 进行链接,再传输/接收 数据。短链接 比较方便,都不需要维护的,每次就是 建立链接 ,把要访问的资源传输,然后关闭链接。长链接的成本就 较高了,因为 它得维护链接,怎么维护呢?比较复杂了,先描述再组织,这么个大体思路。
长链接也常用,因为 长连接 不需要 多次建立链接,建立链接 是有成本的,常听到几句话:三次握手,四次挥手。建立链接是 有成本的。长连接多用于 那种 频繁请求资源的情景。
短链接也有适应它的场景,比如:WEB网站的http服务一般都用短链接,你想吧,如果很多用户 都去长链接,那服务器 早崩了。
至于请求:长连接还是短链接,这个可以在请求报文里看到,在属性里面
这个 keep-alive 就是长链接。
HTTP是应用层协议,它是不关心 数据如何传输到 客户端,这是传输层的事情,它关心的是 你发来的请求报文,要请求的资源,我要给你响应什么资源,这类内容性问题。并且它是无状态协议,无状态就是无记忆,它不关心 客户端 能不能 接收到响应,它只要把响应发到传输层即可。
1.5.3 加密算法以及CA证书
加密算法,我们不去研究 具体算法是怎样的,CA证书也不用深究其原理。我们只需要知道 加密的过程即可。
1.5.3.1 对称加密
所谓对称加密 就是 两方都用的是同一个密钥:
加密的过程,就是 利用密钥 对数据 进行算法加密,比如密钥是 X(1231231232121),它可以是一段数据,我简单的讲昂。
举例子,真正的算法应该较复杂,我就拿异或
举例:
但是单纯的靠对称加密,不安全,就比如第一次的时候,你能确保 密钥就是 服务器发来的密钥吗?
1.5.3.2 非对称加密
非对称加密 是一对 公钥和私钥,它俩配合的使用:
- 公钥加密,私钥解密
- 私钥加密,公钥解密
公钥是公开的,私钥是自己保留的。
非对称加密算法 比较费时间,如果 双方通信 完全使用 非对称加密,就是下面这种情况:
这样其实还是 有泄密的风险,还是那句话,你怎么保证 公钥 是正确的?
比如:不法份子这样做
所以应该怎么办?问题就出在第一步,那么一次交涉的公钥必须有保证,谁来保证?CA认证,它可以保证,具体怎么保证,我们还得学数字签名。
1.5.3.3 数字签名
像上面不法分子做的那样,其实就是 修改了 传输数据的内容,所以 我就得 保证 数据不被修改。
以文本数据为例子:
过程:
发送方:文本数据 通过哈希散列 形成 数据摘要或者是数据指纹,然后利用加密算法对数据摘要进行加密,最终得到唯一的 数字签名。
接收方:对方接收到文本数据后,对数字签名进行解密,形成 数据摘要1。再对文本数据利用同一个哈希散列算法 形成文本数据对应的数据摘要2。然后 摘要1和摘要2 进行对比,就能够 判断出 文本数据有没有被修改。
这就是数字签名 验证公钥的过程。
接下来 简述CA认证:
CA认证即电子认证服务 ,是指为电子签名相关各方提供真实性、可靠性验证的活动。申请CA认证书后,会有一对公钥和私钥,并且申请方还得签名 ,写自己的企业信息,域名之类的。
这个CA认证书上会形成唯一的数字签名,所以第一次交涉,利用CA证书 就是 可靠的。因为如果对CA证书里的内容做修改,就会导致 数字摘要 对不上号。
1.5.3.4 HTTPS加密过程
有了上面的认识,现在就开始讲解,HTTPS加密的过程:
先是非对称加密,利用CA证书有保证,这就是为了保护之后对称加密的私钥X不被窃取。后续就是直接利用私钥进行对称加密,这样效率高。
2. 传输层
应用层负责要处理要发送的数据,传输层负责将数据发送到对方。传输层协议最为常见的协议有UDP,TCP。它们各有优点,有不同的适应场景。
无论是UDP还是TCP,它们都得解决俩个基本问题:
- 如何做到封装和解包?
- 如何向上交付数据?
2.1 首先谈端口号
IP+端口号 就可以标识 具体一台主机上的一个进程,端口号 是用于 标识进程的,端口号的大小是 16比特位,这其实是传输层协议定的。简言之:有了端口号,就能找到对应的进程。
那么提两个问题:
- 一个端口号可以绑定多个进程吗?
端口号不可以绑定多个进程,因为要通过端口号,找到唯一确认的进程。 - 一个进程可以绑定多个端口号吗?
可以,一个进程可以有多个端口号,即便可以通过不同的端口号,找到同一个进程,这也不影响。
端口号的一些常识:
- 端口号范围:
- 0 - 1023: 知名端口号范围,它们的端口号都是固定的。
- 1024 - 65535:: 操作系统动态分配的端口号. 一般程序的端口号, 就是由操作系统从这个范围分配的.
- 知名端口号:以下端口号很常用,所以是固定的
ssh服务器, 使用22端口
ftp服务器, 使用21端口
telnet服务器, 使用23端口
http服务器, 使用80端口
https服务器, 使用443端口
- 查看端口号的指令:
(1) cat /etc/services 这是用于查看知名的端口号。很多。
(2)netstat是一个用来查看网络状态的重要工具,也可以查看端口号。
它的选项:
- n拒绝显示别名,能显示数字的全部转化成数字·
- I仅列出有在Listen(监听)的服矜状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项. u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
(3)pidof +进程名 可以得到进程的id,进程名就是 运行的程序名
2.2 UDP协议
2.2.1 UDP协议的格式
UDP协议的前8字节,就是报头,下面的就是数据段。
报头内容:
- 源端口号:这是发送端的端口号
- 目的端口号:这是接收端的端口号
- UDP长度:总长度,报头+数据的字节大小,
注意:
UDP最大长度是64k,发送的数据很大,如果用UDP协议传输,就需要在应用层,自己手动的分包,多次发送。 - UDP检验和:一个端到端的检验和。 它由发送端计算,然后由接收端验证。 其目的是为 了发现UDP首部和数据在发送端到接收端之间发生的任何改动。 有改动的话,会直接丢弃报文。
2.2.2 UDP的封装和解包
UDP的封装,就是利用上述的UDP协议格式,对数据进行保存以及处理。应用层将数据交付到传输层,如果是UDP协议,那么就会创建一个UDP报头类型的变量,并填充此变量的字段。再将报头和数据拷贝到一起,就形成了UDP报文。
UDP的解包,是一种定长的方式,它规定了 报头的大小为 8字节,那么 除去报文的前8字节,后续的都是数据。
UDP报头类型的变量如何理解呢?
其实就是用C语言写的的一个结构体,并利用到了 位段的知识:
struct UDP_header
{
uint32_t src_port:16; //源端口号
uint32_t dst_port:16;//目的端口号
uint32_t total:16;//UDP长度
uint32_t src_port:16;//UDP检验和
}
2.2.3 UDP向上交付数据
UDP向上交付数据也比较简单,就是通过 报头中的 目的端口号,找到对应的进程,然后将报文 解包 交付给此进程。
2.2.4 UDP的缓冲区
UDP只有接收缓冲区,没有发送缓存区,发送数据直接由内核进行处理。接收缓存区,无法保证UDP报文的接收顺序正确,缓存区满了,就会将到达的报文丢弃。
UDP是全双工的,也就是 可以 一边接收报文,一边发送报文,互不影响。
2.2.5 UDP协议的特点
UDP协议的传输是单方面的:
- 无连接:知道对端的IP+端口号,就可以发送报文,不需要建立链接
- 不可靠:没有确认机制,重传机制,也没有错误信息反馈。简单明了,就是把数据传输过去就完事了,不管对方有没有收到之类的
- 面向数据报:无法灵活的控制读写数据的次数和数量
2.2.6 基于UDP协议的应用层协议
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
- 自己写UDP程序时自定义的应用层协议
2.3 TCP协议
TCP协议,比UDP协议复杂很多,它对传输进行多方面的控制。全称为传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
2.3.1 TCP协议的格式
源端口号,目的端口号,检验和 这三个我们是可以理解的,和UDP那块差不多。
2.3.2 TCP的缓冲区
TCP是有接收缓冲区和发送缓冲区的。
系统调用接口中 read/recv 就是 从接收缓冲区中读取数据。write/send 就是 将数据拷贝到发送缓冲区。
缓存区的存在 提高了应用层效率,它的存在是 传输层和应用层的一种解耦。
2.3.3 TCP的封装和解包(4位首部长度)
TCP的封装就是 填充一下TCP报头再和数据拷贝到一起,形成TCP报文。
解包呢?TCP的报头大小并不是固定的,它还有一个选项块,选项块的大小并不固定,但是报头的前20字节是固定的。所以解包 首先到前20字节中,找到4位首部长度,这个4位首部长度就是标志着报头的总大小。减去报头总大小,后面就是数据段。
报头的大小是有单位的:4字节。所以没有选项块,报头的大小就是20字节,那么4位首部长度就是5(0101)。54=20,首部长度单位长度 = 报头大小。如果有选项块,那么4位首部长度肯定是比5大的。
2.3.4 确认应答机制(序号和确认序号)
TCP协议要有保证可靠性,确认应答机制就是最核心的保证机制。
它保证了 对方收到了 我的报文,并且是按序接收。靠的就是序号
和确认序号
。
举个例子:
服务端向客户端发送了 序号为 1,2,3 的报文。
客户端接收的顺序必然是 1,2,3吗?不一定,有可能它的接收顺序是 3,1,2。那么为了保证读取的有序性,会根据序号进行重排,使其顺序变为1,2,3。
server端一次性发来三个报文,我需要对这三个报文都做确认吗?答案是:不需要。如果三个报文都接收到了,那么我只需要对最后一个进行确认,也就是通过确认序号来确认。
确认序号 = 以确认序号 + 1,表示的意思是 确认序号之前
的报文 我都已经收到。
那么如果出现丢包的现象如何处理?
比如序号为2 的报文丢了,那么响应的确认序号是多少?确认序号是 1 +1 =2。表示的意思是 1报文我收到了,2报文没有收到,请重发。3号报文无论收到没有,都需要被重发。
比如序号为3 的报文丢了,那么响应的确认序号就是 2+1 = 3。表示的就是序号3之前的报文我都收到了。
以上就是确认应答机制,它通过序号排序可以保证读取报文的有序性,通过确认序号可以反馈 报文的接收情况。
2.3.5 流量控制(窗口大小,滑动窗口)
像上面的例子,一下就发出去3个报文,具体可以一次性发送多少个 报文,这也是需要被控制的,对吧?这是好理解的。如果不加控制,一下就发送好多报文,导致对方的接收缓冲区,无法接收,最终只能丢弃报文。所以有了 窗口大小 和 滑动窗口。
16位窗口大小:用来告诉TCP连接对端自己能够接收的最大数据长度。它是更具自己接收缓冲区的剩余空间来做调整的。具体怎么调整,这个不需要太深究。
滑动窗口其实就是发送缓冲区的一段区间,它由俩个下标来控制,滑动窗口的大小。
滑动窗口将发送缓冲区分为了三段:
- 滑动窗口前:已经确认了的报文
- 滑动窗口中:已经发送但没有确认的报文,还包括能发送的报文
- 滑动窗口后:还不能发送的报文
滑动窗口的大小是更具 16位窗口大小 进行调整的,它也是动态的。
以上图为例子,如果应答的报文中,确认序号是3,那么表示3之前的报文都确认被接受了,那么 begin向后移动到 确认序号位置处。end也会根据窗口大小进行调整,假如窗口大小为2,那么end向后移动 2 。
这其实就是网络传输中的吞吐量,滑动窗口越大,表示 可以一次性发送的报文 越多,网络传输效率越高。
窗口大小表示的是接收端 所剩接收缓存区的大小,滑动窗口是发送端,发送缓冲区的一段区间,滑动窗口会根据 应答确认序号情况和发来的窗口大小,做调整。
流量控制是 双方,你给我发,我给你发 这种。下面的拥塞控制 只的是 多个客户端 请求服务端,可能导致拥塞的情况(卡住了),一个服务器被很多人同时访问,只会导致很多问题。
2.3.6 建立链接以及断开链接(标记位,3握4挥)
有了以上的认识,我们来聊聊 TCP双方 是如何建立链接的,它们怎么是 如何确认 双方的链接状态的?
双方进行链接,需要进行3次握手。再讲这个之前,我们先来认识一下6个标志位:
服务端会面临大量的TCP报文,如何区分报文的类别,看的就是标志位,通过标志位来区分。
- URG: 紧急指针是否有效
- ACK: 确认号是否有效
- PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
- RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
- SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
- FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
正常的请求链接,关闭链接,就使用三个标志位:SYN,ACK,FIN。
三次握手:
四次挥手:
RST标志位,它是用于重置链接的,有的时候链接并未建立成功,就需要设置标志位RST,要求重新建立链接。
PSH标志位,它其实是催促的一个信号,要求接收端 快点把数据从接收缓冲区中拿走,这样我才能继续向你发送数据。
URG标志位,因为TCP协议是按序到达的,如果有紧急的数据需要被接收,那么就需要设置URG标志位,但是要注意的是 紧急数据只能是一个字节。TCP协议格式中有一个紧急指针
,通过这个紧急指针,就能够找到TCP发送字节流中的紧急数据。
思考:
- 为什么是三次握手?
三次握手,是能确认双方可以通信的最小次数。
建立链接需要确认这两件事情:
(1)双方主机是否健康,是否可以完成通信
(2)是否能完成全双工,说人话就是:我可以收发,你也能收发
咱们好好看这个三次握手:
-
客户端首先发送SYN,服务端接收后,发出SYN+ACK。这里验证了客户端的发送没有问题,服务端的接收没有问题。
-
客户端接收到服务端的SYN+ACK,又发出了ACK。这里验证了服务端的发送没有问题,客户端的接收没有问题。
- 为什么是四次挥手?
四次挥手,也是完成双方断开链接的最小次数(不包括捎带应答机制情况),而且TCP是全双工的。
(1)第一次挥手 因此当主动方发送断开连接的请求(即FIN报文)给被动方时,仅仅代表主动方不会再发送数据报文了,但主动方仍可以接收数据报文。
(2)第二次挥手 被动方此时有可能还有相应的数据报文需要发送,因此需要先发送ACK报文,告知主动方“我知道你想断开连接的请求了”。这样主动方便不会因为没有收到应答而继续发送断开连接的请求(即FIN报文)。
(3)第三次挥手 被动方在处理完数据报文后,便发送给主动方FIN报文;这样可以保证数据通信正常可靠地完成。发送完FIN报文后,被动方进入LAST_ACK阶段(超时等待)。
(4)第四挥手 如果主动方及时发送ACK报文进行连接中断的确认,这时被动方就直接释放连接,进入可用状态。
这里其实是服务端和客户端的状态需要转换,主动断开链接的一方要进入time_wait状态。就比如:上面的HTTP服务器,我主动的关闭服务器,然后看它的状态
netstat -ntp | grep 端口号
再次绑定端口号,就会出现问题,因为它还处于time_wait状态:
那么为什么会有绑定失败情况?
这是TCP协议的规定,主动关闭链接的一方要进入time_wait状态,处于此状态下绑定的端口号不可以被再次绑定。需要等待两个MSL的时间才可以重新绑定,这时候服务器的状态变为CLOSE。
MSL可以通过指令cat /proc/sys/net/ipv4/tcp_fin_timeout
查看:
我这个是60秒,这个看系统,具体操作系统可能不一样。
这里bind失败的问题解决方法:
使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符
time_wait是很重要的状态,它就是在等两个MSL,再关闭链接:
MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话
就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到
来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK
具体状态的切换,后续会讲的。
2.3.7 拥塞控制(超时重传,快重传)
即便有了滑动窗口,可以进行流量控制。但是如果贸然向网络中发送大量数据,就有可能造成网络拥堵,网络中可能有多台主机在发送数据,所以在发送数据前,先得探探路,避免 网络拥塞。
其实也就是一种对当前网络情况的一种探测手段,用于判断 发送数据的。
具体探测会有三个过程:
- 慢启动机制
- 拥塞避免(线性增长)
- 快速恢复
慢启动机制,就是先发送少量的数据,这个阶段是指数级别的增长,增长到慢启动阈值。
然后达到阈值后,开始线性增长(拥塞避免),因为不能一直指数级增长,必须到达阈值后,开始缓慢增长。继续增长必然会到达一个网络拥堵的点,从这一个点开始断崖式暴跌,发送的数据还很少,接下来就是快速恢复,其实快速恢复就是重复前两个过程,虽然叫慢启动,但是它是指数级别增长。这是一个周期,懂吧。
其实拥塞控制比较难理解,想要深入了解的伙伴,可以去查阅书籍,但是 有我上面的概述,想必看书 也会轻松些。
提三个问题:
- TCP如何限制数据的发送速率;
通过拥塞窗口,上面不是讲过滑动窗口嘛,其实滑动窗口中的一部分就是拥塞窗口,它也会影响滑动窗口的大小:
阴影就是拥塞窗口,这块是受网络情况所影响的,通过控制此块,来控制发送速率。
- TCP如何检测网络中是否拥塞?
看应答报文就可以,检测网络情况。
(1) 有报文丢失的情况,收到了多条重复报文应答确认序号(快重传)
(2)在规定时间内,没受到应答报文(超时重传)
- TCP采用什么算法来调整速率(什么时候调整,调整多少)
这个算法,就是上述中慢启动机制,拥塞避免(线性增长),快速恢复 。看那副图即可。
快重传就是 发送丢包问题,所以一直发的报文中确认序号一样,确认信号一样这就表明了丢包,所以会重新发送。
超时重传,可能是网络出现问题,或者是丢包问题,甚至是对方主机出现问题。只要超过那个时间,还没有被确认,那么就会重新发送报文。
关于这个时间,也有说法,它不能太长,也不能太短,所以是动态的:
比如:
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时
时间都是500ms的整数倍.
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
如果仍然得不到应答, 等待 4 * 500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
2.3.8 TCP的应答机制补充
其实这个上面已经简述过了,但是TCP协议为了提高通信效率,有了延迟应答和捎带应答。
(1)延迟应答
传输效率最主要还是 滑动窗口,但是 如果可以减少应答的发送次数,这也是变相的提高效率。
延迟应答,就是 等一会,让客户端再发送些报文,做一次应答,就确认了更多的报文。
也不是所以情况下,都可以进行延迟应答,这是有限制的。
- 数量限制:每隔N个报文,做一次延迟应答
- 时间限制:超过最大延迟时间,再做应答
(2)捎带应答
虽然有延迟应答,但是客户端和服务器在应用层还是还是”一发一收”,此时就会导致数据传输效率低下,捎带应答就是接收端在给发送端发送数据的时候,捎带着向发送端发去确认应答,应答的内容是接收端已经收到发送端发送的数据。
最常见的应用的就是 三次握手中:
这里面 明显携带了 SYN信息,这其实就是捎带应答。我再ACK你的同时,我捎带上SYN信息,询问你一下:可以链接你吗?
其实四次挥手,也可以变成三次挥手,这也是捎带应答的应用:
2.3.9 TCP协议与应用层接口之间的关系
TCP协议双方状态有11种:
- CLOSED:初始状态,表示TCP连接是“关闭着的”或“未打开的”。
- LISTEN :表示服务器端的某个SOCKET处于监听状态,可以接受客户端的连接。
- SYN_RCVD :表示服务器接收到了来自客户端请求连接的SYN报文。在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用netstat很难看到这种状态,除非故意写一个监测程序,将三次TCP握手过程中最后一个ACK报文不予发送。当TCP连接处于此状态时,再收到客户端的ACK报文,它就会进入到ESTABLISHED 状态。
- SYN_SENT :这个状态与SYN_RCVD 状态相呼应,当客户端SOCKET执行connect()进行连接时,它首先发送SYN报文,然后随即进入到SYN_SENT 状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT 状态表示客户端已发送SYN报文。
- ESTABLISHED :表示TCP连接已经成功建立。
- FIN_WAIT_1 :这个状态得好好解释一下,其实FIN_WAIT_1 和FIN_WAIT_2 两种状态的真正含义都是表示等待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET进入到FIN_WAIT_1 状态。而当对方回应ACK报文后,则进入到FIN_WAIT_2 状态。当然在实际的正常情况下,无论对方处于任何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1 状态一般是比较难见到的,而FIN_WAIT_2 状态有时仍可以用netstat看到。
- FIN_WAIT_2 :上面已经解释了这种状态的由来,实际上FIN_WAIT_2状态下的SOCKET表示半连接,即有一方调用close()主动要求关闭连接。注意:FIN_WAIT_2 是没有超时的(不像TIME_WAIT 状态),这种状态下如果对方不关闭(不配合完成4次挥手过程),那这个 FIN_WAIT_2 状态将一直保持到系统重启,越来越多的FIN_WAIT_2 状态会导致内核crash。
- TIME_WAIT :表示收到了对方的FIN报文,并发送出了ACK报文。 TIME_WAIT状态下的TCP连接会等待2*MSL(Max Segment Lifetime,最大分段生存期,指一个TCP报文在Internet上的最长生存时间。每个具体的TCP协议实现都必须选择一个确定的MSL值,然后即可回到CLOSED 可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带FIN标志和ACK标志的报文时(捎带应答),可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。(这种情况应该就是四次挥手变成三次挥手的那种情况)
- CLOSING :这种状态在实际情况中应该很少见,属于一种比较罕见的例外状态。正常情况下,当一方发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING 状态表示一方发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?那就是当双方几乎在同时close()一个SOCKET的话,就出现了双方同时发送FIN报文的情况,这是就会出现CLOSING 状态,表示双方都正在关闭SOCKET连接。
- CLOSE_WAIT :表示正在等待关闭。怎么理解呢?当对方close()一个SOCKET后发送FIN报文给自己,你的系统毫无疑问地将会回应一个ACK报文给对方,此时TCP连接则进入到CLOSE_WAIT状态。接下来呢,你需要检查自己是否还有数据要发送给对方,如果没有的话,那你也就可以close()这个SOCKET并发送FIN报文给对方,即关闭自己到对方这个方向的连接。有数据的话则看程序的策略,继续发送或丢弃。简单地说,当你处于CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。
- LAST_ACK :当被动关闭的一方在发送FIN报文后,等待对方的ACK报文的时候,就处于LAST_ACK 状态。当收到对方的ACK报文后,也就可以进入到CLOSED 可用状态了。
这就是TCP协议中的11种状态情况,对照着上图学习。
那么我们来结合应用层系统接口,来学习一下TCP协议,毕竟我们是在应用层里写代码,写下代码,TCP层都进行了什么操作呢?这里得讲讲。
- 接收链接前的准备工作:
使服务器进入LISTEN状态:
- listenfd = socket(),分配一个文件描述符
- bind(listenfd,端口号),绑定端口号
- listen(listenfd,连接队列长度),使listenfd成为一个监听描述符
客户端:
fd = socket(),分配一个文件描述符
- 三次握手应用层干了什么?
客户端:
connect(fd,服务器端口号),请求连接,这时先进入SYN_SENT 状态,接收到服务器的SYN+ACK,connect()返回,从而进入ESTABLISHED状态,并发出一个ACK。
服务端:
connfd = accept(listenfd,客户端地址结构体,结构体长度) ,从连接队列中连接一个客户端,这时短暂进入 SYN_RCVD状态,收到客户端的ACK后,会返回一个新分配的fd,用于和客户端通信,进入ESTABLISHED状态。双方都进入ESTABLISHED状态,表明建立连接成功。
- 四次挥手应用层干了什么?
主动断开连接的一方:
- closed(fd),发出FIN报文,进入FIN_WAIT_1状态,接收到对端的ACK进入FIN_WAIT_2状态,接收到对端的FIN报文,进入TIME_WAIT状态。然后等待2*MSL,就进入了关闭连接的状态,CLOSED状态。
被动断开连接的一方:
- 它是通过read()的返回值来判断对端是否请求断开连接,如果read()返回0,表明对方 发送了FIN报文,那么就进入CLOSE_WAIT,在到LAST_ACK 之间的过程,其实就是在处理未发送的数据,也就是收尾工作,进入LAST_ACK状态后,服务端才开始要断开连接。调用closed(connfd),发送FIN报文,接收到对方的ACK后,进入CLOSED状态。
- 发送数据和接收数据,应用层干了什么?
这个就很简答了,就是从接收缓冲区中 read(),向发送缓存区中 write()。
客户端:
read(fd,buf,size)
write(fd,buf,size)
服务端:
read(connfd,buf,size)
write(connfd,buf,size)
2.3.10 面向字节流——粘包问题
TCP是面向字节流的,意思就是 发来的在HTTP层处理中,看的一个一个字节,它并没有单一的分开,所以应用层 没办法 完整的解包。上文说过一个4字节首部长度,它是用于计算报文头部长度的,不要混淆。像是UDP,人家是有一个报文长度的,直接就把报文总长告诉你了,而且 它的报头还是固定大小:8字节;UDP不存在粘包问题。
应用层看到的就是 一段字节,注意:TCP的报头已经被解析了,向上交付的是数据段,多个报文的数据段,发送了粘包,所以要想办法解决 如果 完整单一的 取出各个报文的数据段。
这个解包工作,是应用层的事,传输层复杂将数据 完整的送到你手里,至于怎么 处理 数据那是你的事。
处理粘包问题的方法:
- 固定报文的长度
这个好理解吧,就是固定每个报文的长度,收满了此长度,这就表明这是一个完整的报文的数据段,然后再取数据。
但是这个方法不够灵活,而且用的少昂。
- 以指定字符(串)为包的结束标志
这个办法,就是 发送的数据段的末尾,用特殊的字符做分割,每次读取到特殊字符就表明数据段读取完整。
这个方法还算常见的,比如:FTP协议,发邮件的 SMTP 协议,一个命令或者一段数据后面加上"\r\n"。
- 第三种方式,在数据段前再加一个数据头,数据头是定长的,数据头里填上 数据段的大小
这种方式,就好弄了,拿到数据段,先是取出定长的数据头,然后根据数据头里的数据段长度,取出后续完整的数据段。
2.3.11 listen第二个参数,全连接队列
listen的第二个参数,其实就是表明 全连接队列的长度 - 1。全连接队列的长度 = listen第二个参数 + 1。
如果要想讲清楚全连接队列,我们需要再次对 三次握手的过程,深入探讨。
TCP协议维护了两个队列:
- 半连接队列
- 全连接队列
半连接队列是全连接队列前的 要维护的队列,这个队列保存的是 请求连接的报文,会在这里面排队。
全连接队列维护的是已经建立好连接的队列,但是具体要和哪个报文进行连接,这个也得排队,accept() 会从里面取,一个个的取,服务完一位,走一位。而且进入全连接队列的,都是在半连接队列排好队的,由半连接队列中的 晋升到 全连接队列,会在半连接中除去,这是好理解。
所以之前讲HTTP中的短链接和长连接中的效率问题,考虑的成本问题,在这里也能再次理解,因为 发送连接请求,完成连接,都会在服务器内核中被队列组织,这是耗费空间成本,时间成本的。
3. 分析网络的工具
分析网络的工具 使用 Wireshark 即可。
直接下载即可 wireshark工具。
使用方法:
- 打开的界面:
- 点击要抓包的位置,网络上的抓包,是WLAN。
- 点击进去,就开始捕获了,但是 需要过滤出 你具体要捕获的网络IP,或者端口号,这样方便观察
过滤的方式有多个,这里提供两个,主要用于TCP协议实验捕获:
(1) ip.addr == IP地址
(2) tcp.port == 端口号
在这里填入,过滤内容:
接下来就可以观察抓包过程了,非常详细昂。
这个大家自己下去实验吧,可以很清楚的看到 建立连接的过程,确认机制,断开连接的过程。当然你得启动Http服务对吧。