文章目录
- TCP与UDP的区别
- 网络字节序
- 套接字接口介绍
- sockaddr结构
- 服务端UDP套接字设置
- 客户端UDP套接字设置
TCP与UDP的区别
TCP | UDP |
---|---|
传输层协议 | 传输层协议 |
有连接 | 无连接 |
可靠连接 | 不可靠连接 |
面向字节流 | 面向数据报 |
首先,网络通信模型是分层的,模型的每一层都有属于自己的协议,而协议有很多种,UDP和TCP就是传输层的两套协议,也是使用最多的两套协议。先解析一下两套协议的特性:UDP具有无连接的特性,什么是连接?在打电话时,对方接通电话后我们通常都会“喂”一声,然后对方做出响应,这种双方在通信前的一些确认通信的手段,我们称之为连接。而UDP则具有无连接的特性,遵守UDP协议的数据直接向对方通信,不需要对方的确认,可以理解为邮件的发送。TCP具有有连接的特性,即TCP协议在通信前需要对方的确认,
至于可靠连接与不可靠连接,它们都是描述协议的特性,并没有好坏之分,它们都有属于自己的应用场景,UDP属于不可靠连接,不可靠连接通常用于直播,因为它只负责传输数据,不用管数据是否成功的递达,所以它的传输效率高,比如直播时画面突然模糊就是因为UDP协议的不可靠连接带来的丢包。TCP则属于可靠连接,可靠连接确保了数据的完整性,在数据丢包的情况下会重新发送数据包,通常用于软件或电影的下载,如果下载的数据不完整,下载的文件也就无法使用,所以必须使用可靠连接。
网络字节序
我们知道计算机设备有大端和小端之分,如果计算机在本地工作,我们很难察觉大小端的差别,以大端序为例,计算机在存储数据时,以字节为单位,将数据的高字节放入低地址处,当大端机器读取数据时,肯定是将低地址处的数据作为整体的高字节位,总之就是怎么存储的怎么读,很好理解,对于小端机器的存储与读取,情况也是一样的。但是这样的情况是建立在计算机只在本地工作的情况,即计算机只对本地数据进行操作,如果一台小端计算机要读取一台大端计算机发送来的数据,是不是读取规则就要发生变化了,但是计算机怎么知道接收的数据是小端还是大端?可以通过数据中的一些特定的信息吗,答案是不能,不同字节序的机器解析数据的方式是不相同的,所以将表示字节序的信息放入数据的某些位段后,也无法被不同字节序的机器识别,因此通过数据中的一些特定信息表示数据的字节序是无法做到的。然而,在网络通信种有一个规定:用于网络通信的所有数据都必须是大端字节序,发送要以大端的方式发送,读取也要以大端的方式读取,如果你不采用大端的方式,你的计算机就无法上网。所以小端机器需要将自己的数据转换成大端字节序才能向网络中发送,同样,从网络中接收到的数据也要先转换成小端字节序才能读取,对于大端机器就不用做这样的转换
网络通信时,发送主机通常先发送缓冲区的低地址处数据,然后发送高地址处数据,所以接收主机先收到的数据就是低地址数据,接收主机会将其放到缓冲区的低地址处,往后的数据再往高地址存储。这样两台主机看到数据的地址顺序就是一样的了。但是以字节位单位,低地址处的数据是位于整体的高字节还是低字节呢?因为网络通信规定,网络中的数据都是以大端方式传输的,所以缓冲区的低地址处数据位于整体的高字节,高地址处数据位于整体的低字节。因此小端机器在发送数据前,需要将数据转换成大端格式,满足网络通信的规定。在接收网络中的数据后,需要将其转换成小端格式,使自己能够识别数据。我们可以使用函数进行数据进行大小端转换
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t hostlong);
uint16_t ntohs(uint16_t hostshort);
这套函数接口的名字需要记忆一下:h表示host主机字节序的意思,n表示net网络字节序的意思,l表示long,一个32位的长整数,s表示short,一个16位的短整数。比如htonl就表示将一个32位的host主机字节序数据转换成网络字节序数据
套接字接口介绍
第一个接口socket,创建套接字文件。那么什么是套接字?网络通信中,IP地址确定了广域网中的唯一主机,端口号port确定了主机上的唯一进程,IP + port就可以定位广域网中一台主机上的唯一进程,我们将IP + port这个组合称为套接字。在网络通信中,发送的数据除了要表明通信对方的套接字,还需要表明自己的套接字吗?答案是肯定的,所以我们需要创建一个套接字,以绑定自身的IP + port。socket接口为我们打开了一个套接字文件,该文件用来网络通信(Linux下一切皆文件)
domain:领域,套接字可用于本地通信,还可用于网络通信,将套接字用于本地通信时,将AF_UNIX这个宏作为参数,用于网络通信时,将AF_INET这个宏作为参数
type:通信的数据类型,UDP的数据类型为数据报,TCP为字节流
protocol:协议类型,在网络通信中一般设置为0
对于socket的type,udp传输的数据是面向数据报的,用SOCK_DGRAM这个宏表示数据报,tcp传输的数据是面向字节流的,用SOCK_STREAM表示字节流
sockaddr结构
我们知道套接字支持本地通信和网络通信,本地通信套接字叫做域间套接字,我们平常使用的套接字一般是网络套接字。网络套接字需要保存IP + port,而域间套接字需要保存一个共享文件信息,使进程看到同一个共享文件,然后才能进行通信。所以关于两种通信,需要设计两个不同的套接字结构,对于两种不同的结构也需要设置两套不同的接口来设置两种结构,一套接口设置struct sockaddr_in结构,用于网络套接字(in表示inet网络的意思),一套接口设置struct sockaddr_un结构,用于本地通信(un表示unix),两个结构保存了两种通信所需要的信息。但是设计者不希望使用两套接口设计两种套接字(这也太麻烦了),于是他们提出提出:使用sockaddr结构,用该结构接收struct sockaddr_in和struct sockaddr_un结构,根据struct sockaddr的前16位地址类型判断结构体是struct sockaddr_in还是struct sockaddr_un,判断完成,对结构体进行强转,然后根据对应的结构类型解析其中的数据,这样就能用一套接口创建两种不同套接字,实现了接口的统一(或者说这是早期的C语言对多态的设计)
要进行网络通信,需要先创建套接字,要创建套接字就需要先填充sockaddr结构,由于我们经常使用的套接字是网络套接字,所以需要填充的结构为sockaddr_in,关于套接字的填充,将在后面的demo中展示并解析
填充完struct sockaddr结构就可以调用bind接口将套接字信息绑定指定套接字文件
socket:创建套接字后得到的套接字文件描述符fd
address:保存套接字信息的结构体(sockaddr_in)的地址
address_len:套接字信息的结构体的字节大小
使用bzero将s地址向后n字节的数据写0值,通常用于sockaddr_in结构体的初始化
recvfrom:接收来自有连接或无连接的套接字文件的数据,该函数可以检索数据的源地址(数据的IP和port)
socket:socket文件暴露在网络中,可能会有设备向该socket发送数据,recvfrom可以接收socket文件接收到的网络数据
buffer:recvfrom将socket接收的网络数据存储在buffer缓冲区中
length:buffer缓冲区的字节大小
flags:为0表示阻塞式的接收网络数据
address:输出型参数,类型为struct sockaddr,是保存发送数据的信息的结构体
address_len:输入输出型参数,socklen_t是无符号int类型,address_len表示保存套接字的结构体的字节长度,因为套接字可能是域间套接字还可能是网络套接字,接收方得到address后,需要根据address的大小对其进行强转
sendto:发送信息给网络中的套接字文件
socket:要进行套接字通信,你当然需要一个套接字文件,socket就是你的套接字文件描述符fd
message:发送数据的缓冲区,sendto将message中的信息发送
length:缓冲区的字节大小
flags:为0表示阻塞式发送信息
addr:通信对方的套接字信息结构体
dest_len:套接字信息结构体的字节大小
服务端UDP套接字设置
至此我们可以使用这些接口进行UDP网络通信,假设这样一个场景,网络中有一个服务器,还有很多的客户端,客户端需要向服务器发送消息,服务器根据这些信息为客户端提供服务
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class udpServer
{
public:
udpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _udpfd(-1){};
~udpServer(){};
void init()
{
// 创建套接字
if ((_udpfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1)
{
std::cerr << "socket fail" << std::endl;
exit(-1);
}
// 填充套接字,创建struct sockaddr_in对象
struct sockaddr_in local; // 创建填充对象
bzero(&local, sizeof(local)); // 初始化对象
local.sin_family = AF_INET; // 填充套接字家族
local.sin_port = htons(_port);// 填充套接字端口
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); // 填充套接字IP
// 将套接字信息绑定到套接字上
if (bind(_udpfd, (const struct sockaddr *)&local, sizeof(local)) == -1)
{
// std::cout << local.sin_addr.s_addr << std::endl;
std::cerr << "bind fail" << std::endl;
exit(2);
}
std::cout << "the socket is created successfully" << std::endl;;
}
void start()
{
while (1)
{
std::cout << "the udpService is running..." << std::endl;
sleep(1);
}
}
private:
uint32_t _udpfd;
uint16_t _port;
std::string _ip;
};
void usage(const char *filename)
{
std::cout << "usage:\n\t"
<< filename << " port [IP]" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2 && argc != 3)
{
usage(argv[0]);
exit(1);
}
uint32_t port = atoi(argv[1]);
std::string ip = "";
if (argc == 3)
{
ip = argv[2];
}
udpServer ser(port, ip);
ser.init();
ser.start();
return 0;
}
上面的demo中封装了一个udpServer类,表示一个服务器,类的成员有端口号_port,IP地址_ip以及打开的套接字文件描述符_udpfd。初始化udpServer类的对象时,需要传入一个端口号,为类的成员_port赋值,至于IP就可传可不传了,如果不传IP构造函数会用空字符串初始化_ip成员。udpServer对象构造完成,就需要初始化这个对象,绑定套接字信息到打开的套接字文件上,先调用socket接口,创建套接字对象,该函数返回一个套接字文件fd,将其返回值赋值给_udpfd成员(我们所有的网络通信操作就是对套接字文件的操作,一切皆文件嘛),至此,udpServer对象就有了一个打开的套接字文件。然后我们需要绑定自身套接字信息到这个文件中,在绑定信息之前,需要先填充信息到sockaddr_in这个结构体中
刚才的接口介绍中已经说明了,bind函数会绑定address结构体中的信息到socket文件中,而address的类型struct sockaddr是一个统一的结构体。套接字有两种类型,而这两种类型的套接字所需要填充的信息各不相同,域间套接字需要填充struct sockaddr_un结构体对象,网络套接字需要填充struct sockaddr_in结构体对象,而设计者使用struct sockaddr结构体类型作为bind函数的形参,通过对struct sockaddr对象的前16位数据进行提取,如果提取后的信息表明这是网络套接字,bind函数对struct address进行强转,使其成为struct sockaddr_in,socket文件的绑定就会按照struct sockaddr_in结构体的成员信息绑定。如果前16位数据表明套接字为域间套接字,那么bind还是会对其进行强转,以struct sockaddr_un结构体的成员信息绑定socket文件。
所以要创建网络套接字,就需要将相关信息绑定到套接字文件上,而要表示套接字信息就需要使用struct sockaddr_un或struct sockaddr_in结构体。因为我们要进行网络通信,所以我们使用struct sockaddr_in结构体填充信息
上面的图片是struct sockaddr_in结构体的声明,可以看到它有4个成员
sin_family:表示公共字段,地址类型
sin_port:表示port端口号
sin_addr:表示IP地址
sin_zero:填充字段
其中的sin_family虽然没有直接体现,但是它以宏的方式体现了,__SOCKADDR_COMMON这个宏可以将参数拼接到sa_family_t sa_prefix##family中(宏定义中##的作用是字符串拼接),将sa_prefix用参数替换,比如将sin_作为这个宏的参数,宏被替换后就等于sa_family_t sin_family,sa_family_t sin_family就是结构体中的第一个成员,而sa_family_t是无符号短整形的重定义,所以sin_family是一个短整形类型数据,用来表示套接字所遵守的IP协议标准,一般网络通信都是用IPV4进行通信,我们用AF_INET这个宏表示IPV4协议,所以将AF_INET赋给struct sockaddr_in的第一个成员sin_family
struct sockaddr_in local; // 创建保存套接字的结构体对象
local.sin_family = AF_INET; // 填充套接字家族
第二个成员sin_port用udpServer保存的_port进行初始化即可,但是要注意主机字节序到网络字节序的转换,因为服务器的端口号需要对外公布,会在网络中传输,需要进行字节序转换
local.sin_port = htons(_port);// 填充套接字端口
第三个成员sin_addr是一个结构体,类型为struct in_addr,其只有一个成员s_addr,类型为in_addr_t,该类型是无符号32位整形的重定义。为什么一个32为整形就可以表示一串IP地址?IP地址的表示通常是以点分十进制的方式表示的,比如xxx.xxx.xxx.xxx。我们输入一串IP地址,通常都会被字符串接收,但是字符串的一个字符占用了一个字节,一串IP地址将占用很多的空间,而xxx表示的数据范围为0~255,我们用8个比特位就能表示一个xxx,IP地址有4个xxx,所以我们用32个比特位就能表示整串IP地址。所以我们需要将以字符串形式存储的IP地址转换成无符号32位整形
inet_addr的作用就是将一串用点分十进制表示的IP地址转换成一个32位整数,并且转换后的整数是以网络字节数存储的(可以看到函数返回值类型位in_addr_t,与s_addr的类型相同,我们需要将函数的返回赋值给s_addr),所以调用inet_addr后得到的IP不用再手动调接口转换字节序了。
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 填充套接字IP
但是这样填充IP是有问题的,一个IP地址对应广域网中唯一的一台设备,但是一台设备只能有一个IP地址吗?答案是否定的,我们可以通过不同的IP找到同一个主机(这里可以参考文件系统的文件名与inode关系)。所以一台设备可能有多个IP地址,如果你的套接字只绑定其中的一个IP地址,那么其他设备向你的其他IP地址发送的数据就无法被接收,那么我们需要绑定设备的所有IP到套接字中吗?答案是不用,我们可以使用INADDR_ANY这个宏作为sin_addr结构体成员s_addr的值,这个宏的值是全0,如果用INADDR_ANY填充套接字的IP地址,那么其他设备向你的设备发送数据时,无论它们的目标IP是哪个,只要是你的设备上的IP,你都能接收到,所以绑定任意IP是一个比较推荐的做法。因此对IP地址的填充就变成了
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); // 填充套接字IP
如果用户创建udpServer对象时没有传入指定IP地址,那么就用INADDR_ANY 这个宏填充IP地址信息,如果用户指定了IP地址,那么就用该IP地址填充IP地址信息
至此struct sockaddr_in的三个有效成员被填充好了,剩下一个无效字段我们不需要管他,但是为了严谨,我们可以在创建struct sockaddr_in对象时,用0初始化结构体的所有数据。最后总结一下struct sockaddr_in对象从创建到填充完成的代码
// 填充套接字,创建struct sockaddr_in对象
struct sockaddr_in local; // 创建填充对象
bzero(&local, sizeof(local)); // 初始化对象
local.sin_family = AF_INET; // 填充套接字家族
local.sin_port = htons(_port);// 填充套接字端口
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); // 填充套接字IP
填充完套接字地址信息后,我们就可以使用bind接口将这些信息绑定到套接字文件中,回顾一下bind接口
我们需要传入套接字文件描述符fd作为bind的第一个参数,将刚才填充的struct sockaddr_in对象的地址作为第二个参数,将刚才填充的struct sockaddr_in对象的字节大小作为第三个参数传入bind函数,bind就可以将struct sockaddr_in对象中的端口号,IP等信息绑定到套接字文件中。但是要注意,设计者设计了统一的接口,即用struct sockaddr作为形参接收填充信息的对象,所以我们需要将struct sockaddr_in强转成struct sockaddr再传入bind函数
// 将套接字信息绑定到套接字上
if (bind(_udpfd, (const struct sockaddr *)&local, sizeof(local)) == -1)
{
// std::cout << local.sin_addr.s_addr << std::endl;
std::cerr << "bind fail" << std::endl;
exit(2);
}
std::cout << "the socket is created successfully" << endl;
如果bind函数绑定失败,会返回-1,这里需要判断一下bind是否绑定成功,若绑定失败服务器也就无法正常启动,程序将退出。绑定成功后会打印绑定成功的提示。至此,udp套接字的初始化工作完成,现在udpServer对象中的_udpfd就是一个可以接收网络通信的套接字文件,我们需要测试一下udp套接字的创建是否正确,首先需要通过main函数接收port端口号和IP地址,用端口号和IP地址构造udpServer对象ser,然后调用ser的init方法初始化套接字,这是最重要的部分,然后调用ser的start方法,使服务器运行
我们可以通过命令行的方式给可执行程序IP地址,但是由于我的Linux系统是运行在云服务器上的,云服务器比较特殊,不能绑定云服务器中任何确定的IP地址,如果绑定会导致绑定失败,但是我们能用使用INADDR_ANY绑定主机的所有IP地址。至此,udp套接字的初始化建立并测试完成
使用netstat -lunp可以查看系统中正在进行网络通信的进程
客户端UDP套接字设置
有了响应信息的服务器,我们还需要能发送数据的客户端
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void usage(const char *filename)
{
std::cout << "usage:\n\t"
<< filename << " IP port " << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
usage(argv[0]);
exit(1);
}
// 保存命令行中的IP和port,注意端口号的整形转换
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 填充服务器信息
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET; // 填充套接字家族
server.sin_port = htons(server_port); // 填充服务器端口号
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 填充服务器IP
// 创建套接字
int udpfd = -1;
if ((udpfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1)
{
std::cerr << "socket fail" << std::endl;
exit(-1);
}
std::string message;
while (1)
{
std::cout << "Please Enter#";
std::getline(std::cin, message);
sendto(udpfd, message.c_str(), message.size(), 0, \
(const struct sockaddr*)&server, sizeof(server));
}
return 0;
}
客户端要向服务器发送信息,就需要知道服务器的套接字,上面的demo以命令行的方式获取并保存服务器的IP和端口号,接着创建struct sockaddr_in结构体对象server,将服务器的信息保存到server中(这再服务端的demo中已经详细讲解了,所以不再赘述)。客户端要向服务器进行网络通信,就需要创建自己的套接字文件,所以程序接着调用了socket打开了一个套接字文件,但是客户端不需要将套接字信息绑定到套接字文件中
那为什么服务器需要绑定套接字信息到套接字文件中?因为服务器一旦发布了服务,就需要对外公布该服务器的套接字,这样客户端才能使用网络中的服务,并且服务器的套接字信息不能随意更改,比如今天的服务器用8080这个端口,明天用8081这个端口,套接字信息的不断变换,使得客户端也需要跟着更换服务器的套接字信息,因为客户端需要连接到服务器上,不更新服务器的套接字信息就无法得到服务了,但是客户端的数量是明显多于服务器的,一旦服务器的套接字更换,将影响所有的客户端,所以最好不要随意更换服务器的套接字信息。而一台设备上有很多的客户端,如果一个客户端使用8080这个端口,另一个客户端也使用8080这个端口,那么两个客户端只能同时运行一个,肯定有一个客户端会bind信息失败,所以客户端使用固定的端口号会导致端口号冲突的问题,两个客户端不能使用同一个端口号,因此与其自己绑定固定的套接字信息,不如将绑定的工作交给操作系统,操作系统会随机绑定一个端口号,确保该端口号在被绑定前没有被使用。
所以客户端的代码中没有调用bind函数,假设客户端要向服务器发送信息,我们会创建一个message字符串作为发送信息的缓冲区,信息从键盘中获取,然后调用sendto函数发送信息给网络中的指定套接字文件。我们已经创建了套接字文件,发送信息的缓冲区以及填充了服务器的套接字信息结构体server,所以我们就可以调用sendto函数,将message中的缓冲区信息发送给服务器
sendto(udpfd, message.c_str(), message.size(), 0, \
(const sockaddr*)&server, sizeof(server));
整体代码可以回去看上面的demo,然后修改一下服务器的start函数,保存套接字文件接收的数据到缓冲区中
void start()
{
while (1)
{
char in_buffer[1024] = {0};
char out_buffer[1024] = {0};
sockaddr_in peer; // peer保存发送数据的套接字文件信息
socklen_t peer_len = sizeof(peer);
bzero(&peer, peer_len);
// 读取套接字文件接收到的数据到缓冲区中
recvfrom(_udpfd, in_buffer, sizeof(in_buffer) - 1, 0,
(struct sockaddr *)&peer, &peer_len);
std::cout << in_buffer << std::endl;
}
}
创建两个缓冲区,一个用来发送数据,一个用来接收数据,使用recvfrom函数接收网络中向本设备的套接字文件_udpfd发送的数据,将数据保存到缓冲区in_buffer中,recvfrom的最后两个参数是输出型参数,发送数据的套接字信息会保存到peer结构体中,并且peer_len也会被修改成peer结构体的字节大小。最后服务器会打印出in_buffer中的信息。运行两个程序,使其进行通信(注意客户端需要连接的服务器IP为127.0.0.1,因为测试是在本地完成的,127.0.0.1表示本地环回,数据本地自顶向下流动,再自底向上流回来,被服务器程序接收)
数据可以正常的进行网络通信,至此udp通信的demo完成,基础这个模型还可以扩展服务器的服务,以及客户端的请求,这只是一个使用了Linux系统接口的基本udp通信模型