目录:
再谈“协议”
HTTP协议
认识URL:
urlnecode和urldecode
HTTP协议格式:
HTTP的方法:
简易HTTP服务器:
传输层
再谈端口号:
端口号范围划分:
netstat:
pidof:
UDP协议
UDP协议端格式 :
检验和的解释:
UDP的特点:
面向数据报:
UDP的缓冲区:
UDP使用注意事项:
基于UDP的应用层协议:
TCP协议
TCP协议段格式:
编辑
超时重传机制:
连接管理机制:
理解TIME_WAIT状态:
滑动窗口:
流量控制:
拥塞控制:
延迟应答:
捎带应答:
面向字节流:
粘包问题:
TCP异常情况
TCP小结:
基于TCP的应用层协议:
TCP/UDP对比
用UDP实现可靠传输(经典面试题)
TCP的相关实验
理解listen的第二个参数
Linux网络编程套接字(上)https://blog.csdn.net/Obto_/article/details/132189802
再谈“协议”
协议是一种 "约定". socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的. 如果我们要传输一些"结构化的数据" 怎么办呢?
方案一:
方案二:
无论我们采用方案一还是方案二,亦或者其他的,其目的都是保证一端发送时够早的数据,在另一端能够正确的进行解析,这种约定就是应用层协议
HTTP协议
虽然说应用层协议可以由我们程序员自己来定,但实际上,已经有大佬定义了现成的,又非常好用,HTTP(超文本传输协议)就是其中之一
平时我们俗称的“网址”,其实就是URL
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现.
比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式
比如我搜索:c++那么这两个++就会被转意成"%2B%2B"
//使用该指令可以在linux下查看url的请求
curl -I www.baidu.com
ps:Header中有的属性不止图上这些,这些只是较为常见的...
方法 | 说明 | 支持的HTTP协议版本 |
---|---|---|
GET |
获取资源 | 1.0、1.1 |
POST | 传输实体主体 | 1.0、1.1 |
PUT | 传输文件 | 1.0、1.1 |
HEAD | 获得报文首部 | 1.0、1.1 |
DELETE | 删除文件 | 1.0、1.1 |
OPTIONS |
询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 要求用隧道协议链接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.0 |
UNLINK | 断开链接关系 | 1.0 |
HTTP的状态码 :
类别 | 原因 | |
---|---|---|
1XX | Informational(信息状态码) | 接受的请求正在处理 |
2XX | SUCCESS(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
常见的状态码:200(OK),404(Not Found),403(Forbidden),302(Redirect,重定向)
504(Bad Gateway)
HTTP常见的Header:
HttpServer.hpp
#pragma once
#include
#include
#include
#include "Sock.hpp"
class HttpServer
{
public:
using func_t = std::function;
private:
int listensock_;
uint16_t port_;
Sock sock;
func_t func_;
public:
HttpServer(const uint16_t &port, func_t func) : port_(port), func_(func)
{
listensock_ = sock.Socket();
sock.Bind(listensock_, port_);
sock.Listen(listensock_);
}
void Start()
{
signal(SIGCHLD, SIG_IGN);
for (;;)
{
std::string clientIp;
uint16_t clientPort = 0;
int sockfd = sock.Accept(listensock_, &clientIp, &clientPort);
if (sockfd < 0)
continue;
if (fork() == 0)
{
close(listensock_);
func_(sockfd);
close(sockfd);
exit(0);
}
close(sockfd);
}
}
~HttpServer()
{
if (listensock_ >= 0)
close(listensock_);
}
};
HttpServer.cc
#include
#include
#include
#include
#include
#include
#include
#include "HttpServer.hpp"
#include "Usage.hpp"
#include "Util.hpp"
// 一般http都要有自己的web根目录
#define ROOT "./wwwroot" // ./wwwroot/index.html
// 如果客户端只请求了一个/,我们返回默认首页
#define HOMEPAGE "index.html"
void HandlerHttpRequest(int sockfd)
{
// 1. 读取请求 for test
char buffer[10240];
ssize_t s = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = 0;
// std::cout << buffer << "--------------------\n" << std::endl;
}
std::vector vline;
Util::cutString(buffer, "\n", &vline);
std::vector vblock;
Util::cutString(vline[0], " ", &vblock);
std::string file = vblock[1];
std::string target = ROOT;
if (file == "/")
file = "/index.html";
target += file;
std::cout << target << std::endl;
std::string content;
std::ifstream in(target);
if (in.is_open())
{
std::string line;
while (std::getline(in, line))
{
content += line;
}
in.close();
}
std::string HttpResponse;
if (content.empty())
HttpResponse = "HTTP/1.1 404 NotFound\r\n";
else
HttpResponse = "HTTP/1.1 200 OK\r\n";
HttpResponse += "\r\n";
HttpResponse += content;
send(sockfd, HttpResponse.c_str(), HttpResponse.size(), 0);
}
void TestHandlerHttpRequest(int sockfd)
{
std::string content = "ok1111
";
std::string HttpResponse;
if (content.empty())
HttpResponse = "HTTP/1.1 404 NotFound\r\n";
else
HttpResponse = "HTTP/1.1 200 OK\r\n";
HttpResponse += "Content-Length: 11\r\n";
HttpResponse += "\r\n";
HttpResponse += content;
std::cout << "####start################" << std::endl;
std::cout << HttpResponse << std::endl;
//send(sockfd, content.c_str(), content.size(), 0);
char buf[1024] = {0};
// const char *hello = "Hello, World! Hello, World!
Welcome to my website.
";
const char *hello = content.c_str();
sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);
//write(sockfd, buf, strlen(buf));
write(sockfd, HttpResponse.c_str(), strlen(HttpResponse.c_str()));
// send(sockfd,hello,sizeof(hello),0);
std::cout << "#####end###############" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(0);
}
std::unique_ptr httpserver(new HttpServer(atoi(argv[1]), TestHandlerHttpRequest));
httpserver->Start();
return 0;
}
传输层
负责数据能够从发送端传输接收端
端口号(Port)标识了一个主机上进行通信的不同应用程序
在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);
0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的
1024-65535:OS动态分配的端口号,客户端程序的端口号,就是由OS从这个范围分配的
有一些服务器是非常常用的,人们约定一些常用的服务器用的都是以下这些固定端口号
执行下面的命令, 可以看到知名端口号
cat /etc/services
netstat使用来查看网络状态的工具:
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
查看服务器的进程id
语法:pidof [进程名]
功能:通过进程名查找进程id
UDP协议
UDP的检验和可以帮助接收方验证接收到的UDP数据是否完整、正确,并且未被损坏或篡改。发送方在发送UDP数据包时会计算数据包的检验和,并将该检验和值包含在UDP头部中。接收方在接收数据包时,也会重新计算数据包的检验和,并将计算结果与接收到的检验和进行比较。如果两个值不相等,就说明数据包在传输过程中发生了错误或篡改。
UDP 传输的过程类似寄信:
应用层交给UDP多长的报文,UDP原样发送,不会 拆分也不会合并
用UDP传输100个字节的数据:
- 如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节
UDP没有真正意义上的发送缓冲区,调用sendto会直接交给内核,由该内核数据传给网络协议进行后续的传输动作
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据会被丢弃
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部).然而64K在当今的互联网环境下, 是一个非常小的数字.如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装
NFS:网络文件系统
TFTP:简单文件传输协议
DHCP:动态主机配置协议
BOOTP:启动协议
DNS:域名解析协议
TCP协议
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制
CRC校验:
CRC校验的原理如下:
- 首先,定义一个生成多项式(通常是二进制数),表示为G(X)(如0x8005)。
- 发送方计算数据的校验码,使用生成多项式G(X)进行计算。具体计算过程是将数据按照二进制形式做除法运算,除数为生成多项式G(X)。
- 将计算得到的校验码添加到数据后面,形成带有校验码的数据包,然后发送给接收方。
- 接收方使用相同的生成多项式G(X)进行计算,将接收到的数据进行除法运算,得到一个余数。
- 如果接收方计算得到的余数为0,则说明数据在传输过程中没有发生错误;如果余数不为0,则说明数据发生了错误或者被篡改。
TCP将每个字节的数据都进行编号,即序列号
每一个ACK都带有对应的确认序列号,意思就是告诉发送者,我已经收到了这个序列号之前的所有数据,下一次你从这个序列号+1的后面开始发送
当然还有下面这种情况:
因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果
那么超时的时长如何定义:
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算最大超时时间.
服务端的状态转换:
客户端的状态转换:
可以做一个测试,首先启动server,然后启动client,再将Ctrl-C是server终止后再次运行server
就会绑定失败:
$ ./server
bind error : Address already in use
这是因为虽然server的应用程序终止了,但是TCP协议层的连接并没有完全断开,因此不能再监听同样的server端口
但是为什么是2MSL?
在server的TCP连接没有完全断开之前不允许监听,某些情况不太合理,下面是解决方法
int opt = 1;
setsockopt(listenfd , SOL_SOCKET , SO_REUSERADDR, &opt ,sizeof(opt));
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段.这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候
但是这样一收一发的效率很慢,(就像你去超市买菜,跑一趟就买一根,来回跑1000趟一样)
但是如果我们一次性发送多条数据就可以大大提升效率(指的是你一次多带点菜回来)
那么再这种情况出现丢包,该如何重传?
情况一:数据包已经到达,ACK丢失了
这种情况问题不大,因为可以通过后续的ACK来确认
情况二:数据包直接就丢失了
这种机制也被称作"高速重发控制"(也称“快重传”)
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应.因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)
接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的
TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据
像这样的拥塞窗口增长速度是指数级别的
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小
窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是保证网络不拥塞的情况下,尽量提高传输效率
那么所有的包都可以延迟应答么? 肯定也不是
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收" 的. 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个 "Fine, thank you";那么这个时候ACK就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
- 调用write时, 数据会先写入发送缓冲区中
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区
- 然后应用程序可以调用read从接收缓冲区拿数据
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配(不同于UDP), 例如:
- 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界
对于UDP来说,不存在粘包问题:
进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别
机器重启: 和进程终止的情况相同
机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ
断线之后, 也会定期尝试重新连接
为什么TCP这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能
可靠性:
提高性能:
其他:
TCP/UDP对比
参考tcp的可靠性机制
TCP的相关实验
这里将listen的第二个参数改成2,并且不调用accept
test.server.cc
#include "tcp_socket.hpp"
int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("Usage ./test_server [ip] [port]\n");
return 1;
}
TcpSocket sock;
bool ret = sock.Bind(argv[1], atoi(argv[2]));
if (!ret)
{
return 1;
}
ret = sock.Listen(2);
if (!ret)
{
return 1;
}
// 客户端不进行 accept
while (1)
{
sleep(1);
}
return 0;
}
test.client.cc
#include "tcp_socket.hpp"
int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("Usage ./test_client [ip] [port]\n");
return 1;
}
TcpSocket sock;
bool ret = sock.Connect(argv[1], atoi(argv[2]));
if (ret)
{
printf("connect ok\n");
}
else
{
printf("connect failed\n");
}
while (1)
{
sleep(1);
}
return 0;
}
此时启动 3 个客户端同时连接服务器, 用 netstat 查看服务器状态, 一切正常.
但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了
客户端状态正常, 但是服务器端出现了 SYN_RECV 状态, 而不是 ESTABLISHED 状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
而全连接队列的长度会受到 listen 第二个参数的影响,全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态,这个队列的长度通过上述实验可知, 是 listen 的第二个参数 + 1