序言
在上一篇文章中,我们介绍了 协议,协议就是一种约定,规范了双方通信需要遵循的规则、格式和流程,以确保信息能够被准确地传递、接收和理解。
在这篇文章中我们将介绍怎么进行跨网络数据传输,在这一过程中相信大家肯定可以加深对协议的理解。
端口号
1. 端口号的作用
我们已经理解了什么是 IP
,IP
用于标识互联网上的每个设备。我们可以通过他,将数据包能够从一个设备跨网络传输到另一个设备。但是数据发送到设备上,还需要正确地被处理这才是目的吧!
举个栗子,大家平时也刷抖音吧!我们所看到的视频就是抖音平台所跨网络传输给我们的数据,但是有没有可能我们手机在刷抖音的同时还有其他程序也正在运行。那么数据是怎么正确地被抖音所接受的而不是其他程序。
每一个运行的程序以进程的方式存在于内存中,所以抖音肯定也是。所以我们使用唯一的端口来标识内存中需要进行网络传输的进程,当数据到达设备时就会根据端口号选择进程
。
2. 再识端口号
端口号存在于传输层协议层:
- 端口号是一个
2 字节 16 位的整数
- 端口号用来
标识一个进程
, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理 IP 地址 + 端口号
能够标识网络上的某一台主机的某一个进程- 一个端口号只能被一个进程占用
第三点大家如何理解呢?一个 IP地址
标识的是网络是唯一的设备,而 端口号
标识的是设备中唯一的一个进程,两者一起就是标识 网络上的某一台主机的某一个进程
。
所以实际上的网络传输,不就是跨设备跨网络的进程间通信吗?
3. 端口号的需求
服务端在运行时需要指定一个固定的端口号,这样客户端才能根据你的 IP地址,端口号
来找到需要进行通信的进程。但是端口号也不是随便取的,是有要求的:
0 - 1023
: 知名端口号,HTTP, FTP, SSH
等这些广为使用的应用层协议, 他们的
端口号都是固定的.1024 - 65535
: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作
系统从这个范围分配的.
所以说我们只能在 1024 - 65535
进行选择。
客户端就稍显不同了,客户端就不需要指定一个固定的端口号,这又是为什么呢?对于一个服务器来说,他的设备上只是运行了他的业务程序,而对于我们客户端来说,我们的设备上可能同一时间运行着很多程序。如果每一个客户端都固定一个端口的话,很 可能不同的客户端之间就会造成冲突
!所以为了避免这种情况,每次运行时操作系统都会为客户端需要跨网络通信的程序自动分配一个端口
!
简单认识传输层协议
在传输层有很多协议(不同的传输方式),我们主要简单介绍两种 UDP, TCP
,在这里只是简单介绍,在后面会详细原理。
1. UDP 协议
UDP协议
适用于 实时性要求较高、对数据可靠性
要求较低的应用场景,如音频、视频传输(流媒体)、DNS解析、广播和多播等:
UDP
是一种无连接
的传输层协议,提供面向事务的简单不可靠信息传送服务。- 数据以数据报的形式独立发送,
不保证数据的可靠性、顺序性和完整性。
UDP
协议开销小,传输速度快,适用于对实时性要求较高、对数据可靠性要求较低的应用场景。
2. TCP 协议
TCP协议
适用于对数据完整性、顺序性要求较高的应用场景,如网页浏览(HTTP)、文件传输(FTP)、邮件传输(SMTP)等:
TCP
是一种面向连接的、可靠的、基于字节流
的传输层协议。- 在通信双方之间建立一个虚拟的连接,然后在这个连接上进行数据的传输和控制。连接的建立和释放需要经过三次握手和四次挥手的过程。
- 通过
序号、确认号、重传机制、校验
和等手段,保证数据在传输过程中不会出现丢失、重复、乱序或错误的情况。
3. 总结
现在大家就需要简单理解为 UDP
是不可靠的,数据传输可能会丢失部分信息,而 TCP
是可靠的,数据传输的完整性高。大家就会觉得,那我以后肯定选后后面的呀,因为他 可靠
嘛!不是这样的,可靠
也是需要代价的,需要你有稳定且高速的网络服务!
协议的选择要看具体的场景!就比如视频的传输就最好选在前者, 所以你看视频的时候偶尔会卡一下,但无关大雅!传文件就选后者,因为你需要你的文件是完好的,文件如果传过来丢失一部分数据那还怎么看!
网络字节序
1. 什么是网络字节序
大家知道一个概念叫做 大小端
吗?大端机是指将数据的高位存储到内存的低地址,而小端机是指将数据的低位存储到地址的低地址:
所以很可能你的设备是大端机,而需要接受数据的设备是小端机,为了解决这个问题提出了 网络字节序
:
TCP/IP协议
规定,网络数据流应采用大端字节序
,即低地址高字节.- 不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送 / 接收数据
- 如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略,直接发送即可
2. 相关接口
当然这个过程不需要我们自主实现,已经存在相应的接口了:
我们怎么来方便的记忆呢?h
代表 host(主机)
, n
代表 network(网络)
,l
代表 long
,s
代表 short
。我们现在随便选一个来解释,就第三个吧:代表 网络字节序转主机字节序,32位
。
sockaddr 结构
该结构体用于定义和存储 IPv4地址
以及相关的端口信息:
我们主要使用第二个结构体,第三个是用于一个主机上的进程间通信,那第一个是干嘛的呢?这个结构体本身只提供了一个非常基础的框架,不能进行跨网络通信或者是进程间通信,但他在底层 提供一个统一的接口
,根据你第一个参数判断你的通信类型。这不就是 C语言 的多态吗?
UDP 网络编程
在这个版本我们将使用 UDP协议
来进行网络编程,我们将实现一个客户端用于发送信息,服务端用于接收消息:
1. Server 服务端
首先我们需要指定,启动程序时需要指定 IP 端口
:
cpp
int main(int argc, char* argvs[])
{
if(argc != 3)
{
std::cout << "Usage: ./server ip port" << std::endl;
exit(1);
}
}
我们根据相应的内容来初始化 struct sockaddr_in
结构体的相关内容:
cpp
// 初始化结构体字段
struct sockaddr_in address;
address.sin_family = AF_INET; // 网络通信
address.sin_addr.s_addr = inet_addr(IP.c_str()); // 将点分十进制的字符串改为长整型 127.0.0.1 => 0x7F000001
address.sin_port = htons(PORT); // 将端口号转化位网络字节序
之后我们创建套接字文件,我们的读取和发送数据都需要经过该文件:
cpp
// AF_INET 代表网络通信
// SOCK_DGRAM 代表 UDP 协议
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
perror("socket:");
}
现在我们将套接字结构体和文件绑定,代表该文件服务于指定端口:
cpp
// 绑定socket到端口
int n = bind(sockfd, (struct sockaddr*)(&address), sizeof(address));
if(n < 0)
{
perror("bind:");
exit(0);
}
接收消息处理函数稍微有点长,但是思路是很简单的:
cpp
// 不断地接受客户端的信息
char msg[1024];
struct sockaddr_in clientAddr;
socklen_t len = sizeof(clientAddr);
while(true)
{
int n = recvfrom(sockfd, msg, sizeof(msg), 0, (struct sockaddr*)(&clientAddr), &len); // 接收消息
if(n < 0)
{
perror("recvfrom:");
continue;
}
else if(n == 0)
{
std::cout << "Client Quit..." << std::endl;
exit(0);
}
msg[n] = '\0';
printf("[%s:%s]# %s\n", inet_ntoa(clientAddr.sin_addr),
ntohs(clientAddr.sin_port),
msg);
}
首先我们定义一个缓冲区 msg
接收返回值,之后通过 recvfrom
接受信息,之所以还需要传入一个 clientAddr
的原因是因为,这是发送方的信息,你总得知道谁发给你打吧?之后我们有三种情况:
- 接受失败,返回 -1 。我们等待一下发送
- 客户端退出,返回 0 。我们也退出
- 成功接收,返回发送的字符数
inet_ntoa
这个该函数是将 IP地址
转化为我们熟悉的字符串形式,ntohs
将网络字节序的端口号转化为主机序列。
2. Client 客户端
客户端有很多步骤是和服务端类似的,但是整体少简单因为:
client
端口不需要用户指定,OS
自动分配client
不需要显示的绑定自己的端口和IP
- 在首次向服务器发送信息时,会自动绑定
所以我们直接先通过参数获取服务端的信息,并初始化对应结构体:
cpp
if(argc != 3)
{
std::cout << "Usage: ./server ip port" << std::endl;
exit(1);
}
// 获取 IP
std::string IP = argv[1];
// 获取端口号
uint16_t PORT = std::stoi(argv[2]);
// 初始化结构体字段
struct sockaddr_in address;
address.sin_family = AF_INET; // 网络通信
address.sin_addr.s_addr = inet_addr(IP.c_str()); // 将点分十进制的字符串改为长整型 127.0.0.1 => 0x7F000001
address.sin_port = htons(PORT); // 将端口号转化位网络字节序
现在我们还需要创建套接字文件:
cpp
// AF_INET 代表网络通信
// SOCK_DGRAM 代表 UDP 协议
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
perror("socket:");
}
我们客户端不需要显示绑定端口和 IP
,现在可以直接构造发送消息的逻辑:
cpp
// 不断地向服务端发送信息
std::string msg;
while(true)
{
std::cout << "Please Enter# ";
std::cin >> msg;
ssize_t n = sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)(&address), sizeof(address));
if(n < 0)
{
perror("sendto:");
continue;
}
}
3. 总结
其实很多时候服务端不需要指定 IP地址
,因为一个设备可能有很多 IP地址
,为了接受来自所有不同地址的请求,我们会设置:
address.sin_addr.s_addr = INADDR_ANY;
这代表接受所有 本设备 IP地址
的请求,处理数据。
我们在之前提到过, UDP
是一种 无连接
的传输层协议。怎么体现呢?在这里我只是启动客户端程序,不启动服务端,然后发送消息:
可以看到即使是服务器不在线,我们依然能够发送消息!
TCP 网络编程
我们将使用 TCP协议
来实现相同的功能:
1. Server 服务端
前面的步骤都是一样的,接受端口号,初始化结构体字段,但是在创建套接字文件时,就需要更改一下选项了:
cpp
// SOCK_STREAM 代表 TCP 协议
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
perror("socket:");
}
std::cout << "Successful create sockfd..." << std::endl;
之后绑定也是一样的,但是我们不能直接使用该套接字进行收发消息!该文件用于监听,查看是否有客户端的请求:
cpp
// 监听,第二个参数大家先不管,后面理论会介绍
n = listen(sockfd, 3);
if(n < 0)
{
perror("listen:");
exit(1);
}
std::cout << "Successful listening..." << std::endl;
监听之后,当有链接请求发送时,我们需要接受该请求,系统会返回一个进行数据读写的文件描述符:
cpp
// 连接
struct sockaddr_in ClientAddress; // 用于接收客户端的信息
int newsockfd = accept(sockfd, (struct sockaddr*)(&ClientAddress), sizeof(ClientAddress));
if(newsockfd < 0)
{
perror("connect:");
exit(1);
}
std::cout << "Successful accept..." << std::endl;
之后便是接受信息哪些步骤:
cpp
// 不断地接受客户端的信息
char msg[1024];
while(true)
{
int n = read(newsockfd, msg, sizeof(msg));
if(n < 0)
{
perror("read");
continue;
}
else if(n == 0)
{
std::cout << "Client Quit..." << std::endl;
exit(0);
}
msg[n] = '\0';
printf("[%s:%d]# %s\n", inet_ntoa(ClientAddress.sin_addr),
ntohs(ClientAddress.sin_port),
msg);
}
2. Client 客户端
客户端的流程前面都一样,获取 IP地址,端口号
,以及初始化结构体字段,创建套接字文件,但是他需要一次连接请求:
cpp
// 连接请求
int n = connect(sockfd, (struct sockaddr*)(&address), sizeof(address));
if(n < 0)
{
perror("connect:");
exit(1);
}
std::cout << "Successful connect..." << std::endl;
当连接成功时,就可以正常的通信了:
cpp
// 不断地向服务端发送信息
std::string msg;
while(true)
{
std::cout << "Please Enter# ";
std::cin >> msg;
ssize_t n = send(sockfd, msg.c_str(), msg.size(), 0);
if(n < 0)
{
perror("send:");
continue;
}
}
3. 总结
TCP
是一种 面向连接的、可靠的、基于字节流
的传输层协议。现在我们不启动服务端,直接启动客户端发送消息:
可以看到直接拒绝我们的连接,和 UDP
截然不同。
总结
在这篇文章中我们介绍了在实践上如何进行套接字编程,但是我们并没有深入的理解理论的知识,希望大家有所收获!