简单UDP网络程序
目录
UDP网络程序服务端
封装 UdpSocket
服务端创建套接字
服务端绑定
运行服务器
UDP网络程序客户端
客户端创建套接字
客户端绑定
运行客户端
通过上篇文章的学习,我们已经对网络套接字有了一定的了解。在本篇文章中,我们将基于之前掌握的知识进行实际运用,动手实现一个简单的 UDP 网络程序。
UDP网络程序服务端
封装 UdpSocket
服务端创建套接字
我们为了使得代码整体看起来比较的简介,这里进行封装,将服务器封装成一个类。
当我们定义出一个服务器类后,首先要做的就是进行初始化,进行初始化的第一件事就是进行创建套接字。
socket函数
创建套接字的函数叫做socket,该函数的函数原型如下:
int socket(int domain, int type, int protocol);
参数说明:
domain
:套接字创建时使用的域(也称为协议族),用于指定套接字的类型。该参数对应struct sockaddr
结构的前16位。若为本地通信,应设置为AF_UNIX
;若为网络通信,则通常使用AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:套接字所需的服务类型。常见的有SOCK_STREAM
和SOCK_DGRAM
两种。基于 UDP 的网络通信应选用SOCK_DGRAM
(用户数据报服务);而基于 TCP 的网络通信则使用SOCK_STREAM
(流式套接字),提供可靠的流式数据传输服务。protocol
:套接字所使用的协议类型。可以显式指定为 TCP 或 UDP,但通常建议直接设置为0
,表示使用默认协议。系统会根据前两个参数(domain
和type
)自动推断应采用的协议。
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
创建套接字时我们需要填入的协议家族就是AF_INET
,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM
,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>class UdpServer
{
public:bool InitServer(){// 创建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 创建套接字失败std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符
};
除此之外,我们还理应当设置析构函数,当程序结束或者服务器关闭时,理应当关闭对应的文件描述符对应的文件。
测试,检擦是否创建成功:
#include "UdpServer.hpp"int main()
{UdpServer* svr = new UdpServer();svr->InitServer();svr->~UdpServer();return 0;
}
运行结果如下:
运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3,这也很好理解,因为0、1、2默认被标准输入流、标准输出流和标准错误流占用了,此时最小的、未被利用的文件描述符就是3。
服务端绑定
现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
这里需要调用的函数就是bind函数,同样需要提醒的是,我们写的这个是UDP,所以是不连接的,所以第二件是为绑定,与TCP略有不同。
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
struct sockaddr_in结构体
在上篇文章中,仅仅介绍了sockaddr的三种结构的适用场景与区别,然后进行了简单的使用介绍,那么下面就看源码进行了解。
因为我们是跨网络通信,所以使用的是sockaddr_in。
在该文件中就可以找到struct sockaddr_in
结构的定义,需要注意的是,struct sockaddr_in
属于系统级的概念,不同的平台接口设计可能会有点差别。
可以看到,struct sockaddr_in
当中的成员如下:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
其中sin_addr的类型是struct in_addr
,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
剩下的字段一般不做处理,当然你也可以进行初始化。
如何理解绑定?
简单的来说,socket就像我们买了一部手机,这部手机本身没有任何身份,它可以用来打给任何人,也可以接听任何电话,但别人不知道如何联系到你。
bind就好比办一个手机卡并公布号码,在这个过程中,bind它将这个“手机号码”(网络地址)与你的“手机”(套接字)绑定在一起。
技术角度来说就是:bind
系统调用的作用是将一个协议地址(IP地址 + 端口号)分配给一个套接字(socket)。
所以bind
的本质就是“挂牌营业”。 它告诉操作系统:“我这个套接字就在这个IP地址的这个端口上提供服务了,所有发往这个地址的数据包都交给我来处理!”
增加IP地址与端口号
所以我们根据bind的参数,得知我们除了要有sockfd,还需要知道IP与端口号。
所以为刚才的类添加成员变量。
// ...class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool InitServer(){// ...}~UdpServer(){// ...}
private:int _sockfd; // 文件描述符int _port; //端口号std::string _ip; //IP地址
};
注意: 虽然这里端口号定义为整型,但由于端口号是16位的,因此我们实际只会用到它的低16位。
服务端绑定
套接字初始化完成后,就需要进行绑定了,但在绑定之前,我们需要先自己创建一个
struct sockaddr_in
类型的变量,将对应的网络属性信息填充到该结构当中。由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
还需要注意的就是,我们的IP格式,因为我们类中的成员IP变量是string类型的,因为在网络中通信的规定,我们还需要先调用c_str()将其转化为字符串,然后再转化为整形IP的形式,此时我们需要调用inet_addr函数将字符串IP转换成整数IP。除此之外还需要注意的就是网络字节序的问题,因为网络中传输使用的是大端序,所以我们在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用htons函数将端口号转为网络序列。
当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*
强转为struct sockaddr*
类型后再进行传入。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool InitServer(){// 创建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 创建套接字失败std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;//填充网络通信相关信息struct sockaddr_in local;memset(&local, '\0', sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = inet_addr(_ip.c_str());//绑定if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){ //绑定失败std::cerr << "bind error" << std::endl;return false;}std::cout << "bind success" << std::endl;return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符int _port; //端口号std::string _ip; //IP地址
};
同样我们可以进行再次封装,将初始化的函数整体看起来简便些。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool Socket(){_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 创建套接字失败std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;return true;}bool Bind(){//填充网络通信相关信息struct sockaddr_in local;memset(&local, '\0', sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = inet_addr(_ip.c_str());//绑定if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){ //绑定失败std::cerr << "bind error" << std::endl;return false;}std::cout << "bind success" << std::endl;return true;}bool InitServer(){// 检查socket创建是否成功if (!Socket()) {return false;}// 检查绑定是否成功if (!Bind()) {close(_sockfd);_sockfd = -1;return false;}return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符int _port; //端口号std::string _ip; //IP地址
};
运行服务器
UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。
服务器持续运行,其核心使命就是周而复始地为客户端提供特定服务。正因如此,服务器程序一旦启动,便通常不会主动退出,其内部逻辑往往通过一个循环结构不断执行,以保持长时间的服务能力。
UDP 服务器采用无连接通信模式,这意味着它无需建立和维护连接状态。一旦启动完成,UDP 服务器即可随时接收来自任何客户端的数据报,直接读取对方发送的信息,并进行相应处理,处理完一个数据后,进行返回,然后执行下一次循环,等待下一次发来的数据,进行周而复始的操作。
所以整体的代码就是一个死循环,可以使用for,也可使用while,这里使用for。
recvfrom函数
接收客户端发来的数据的函数是recvfrom函数。该函数的函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示从该文件描述符索引的文件当中读取数据。
- buf:读取数据的存放位置。
- len:期望读取数据的字节数。
- flags:读取的方式。一般设置为0,表示阻塞读取。
- src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
注意:
- 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
- 由于recvfrom函数提供的参数也是
struct sockaddr*
类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*
类型进行强转。
启动服务器函数
服务器通过 recvfrom
函数读取客户端发来的数据时,现在视为接收到的数据为字符串。所以为了确保字符串正确终止,应在数据末尾手动添加 '\0'
。这样,接收到的内容就可以直接用于输出或后续的字符串操作。
同时,我们还可以获取并输出客户端的地址信息,包括IP地址和端口号。需要注意的是:
-
获取到的客户端端口号是网络字节序格式,所以要在输出前应当使用
ntohs
函数将其转换为主机字节序。 -
获取到的客户端IP地址是一个整型的网络字节序地址,应当使用
inet_ntoa
函数将其转换为点分十进制格式的字符串后再进行输出。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>#define SIZE 128class UdpServer
{
public:UdpServer(std::string ip, int port)// ...{}bool InitServer(){// ...}void Start(){char buffer[SIZE];for(;;){struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if (size > 0){buffer[size] = '\0';int port = ntohs(peer.sin_port);std::string ip = inet_ntoa(peer.sin_addr);std::cout << ip << ":" << port << "# " << buffer << std::endl;}else{std::cerr << "recvfrom error" << std::endl;}}}~UdpServer(){// ...}
private:int _sockfd; // 文件描述符int _port; //端口号std::string _ip; //IP地址
};
如果调用recvfrom函数读取数据失败,我们可以打印一条提示信息,但是不要让服务器退出,因为考虑到实际情况,服务器不能因为读取某一个客户端的数据失败就退出,不能因为别人的问题,而自己去承担,应该自己的问题自己承担,所以对此应该是客户端去处理。
sendto函数
同样我们还需要对客户端发来的数据进行处理,然后进行返回,这里返回处理后的数据使用到的函数是sendto函数,该函数的函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
- buf:待写入数据的存放位置。
- len:期望写入数据的字节数。
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。 addrlen:传入dest_addr结构体的长度。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
注意:
- 由于sendto函数提供的参数也是
struct sockaddr*
类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*
类型进行强转。
补充启动客户端函数
对于数据我们这里为了方便,就将原数据不进行处理,而是再数据前加一个server get->,然后直接返回。
void Start()
{char buffer[SIZE];for(;;){struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if (size > 0){buffer[size] = '\0';int port = ntohs(peer.sin_port);std::string ip = inet_ntoa(peer.sin_addr);std::cout << ip << ":" << port << "# " << buffer << std::endl;}else{std::cerr << "recvfrom error" << std::endl;}std::string echo_msg = "server get->";echo_msg += buffer;sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);}
}
那么我们就先对其进行简单的测试,看看有没有语法错误。
这里的参数我是随便写的,对于真正的测试还需要编写客户端的代码,才可以知道start的逻辑是否有错,这里只可以证明初始化没有错,start没有语法错误与越界之类的问题,真正的逻辑问题不能检测。
#include "UdpServer.hpp"int main()
{std::string server_ip = "127.0.0.1";int server_port = 8080;UdpServer* svr = new UdpServer(server_ip, server_port);svr->InitServer();svr->Start();svr->~UdpServer();return 0;
}
但是我们在main函数中设置这一些ip与端口号的信息,多少有点影响美观,所以我们将其设置在.hpp文件内。
设置 IP = "0.0.0.0"
表示服务器将监听本机所有可用的网络接口(网卡)上的指定端口。而不仅仅是我们最一开始设置的127.0.0.1,只可以收到本机发的数据。
到这里我们就简单的实现了一个UDP网络程序,虽然十分简单,但也向前迈出了第一步。
下面我们就紧跟编写客户端的代码,然后进行测试我们写的服务端代码是否真正无错误。
UDP网络程序客户端
同样的,我们把客户端也封装成一个类,当我们定义出一个客户端对象后也是需要对其进行初始化,而客户端在初始化时也需要创建套接字,之后客户端发送数据或接收数据也就是对这个套接字进行操作。
客户端创建套接字
客户端创建套接字时选择的协议家族也是AF_INET
,需要的服务类型也是SOCK_DGRAM
,当客户端被析构时也可以选择关闭对应的套接字。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpClient
{
public:bool InitClient(){//创建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){std::cerr << "socket create error" << std::endl;return false;}return true;}~UdpClient(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; //文件描述符
};
客户端绑定
与之不同的客户端绑定问题
在网络通信中,通信双方都需要通过IP地址和端口号来定位对方。服务器和客户端虽然都具备各自的IP地址和端口号,但它们在端口号的使用方式上存在重要区别。
服务器作为服务的提供方,必须明确告知外界自己的访问地址。通常,服务器通过域名公开其IP地址,而端口号则往往不会显式地对外公布。因此,服务器必须使用一个众所周知的、固定的端口号,并且在选定之后不能随意更改,否则客户端将无法得知应当连接至哪个端口。这正是服务器需要主动绑定端口的原因——通过调用 bind
系统调用,服务器独占该端口,确保同一时刻只有一个进程能够在此端口上提供服务。
相反,客户端虽然同样需要端口号进行通信,但通常不需要主动绑定端口。客户端访问服务端时,仅要求其端口号在当前系统中是唯一的,而不必与某一特定进程长期关联。
如果客户端绑定了某个固定端口,会导致几个问题:首先,该端口将被独占,即使客户端未运行,其他程序也无法使用该端口;其次,若该端口已被占用,客户端程序将无法启动。因此,客户端的端口分配更适合采用动态方式,无需人工指定。当调用 sendto
等网络接口时,操作系统会自动为其分配合适的、当前未被使用的临时端口号。
也就是说,客户端每次启动时使用的端口号可能是不同的。只要系统中有可用的临时端口,客户端就能正常启动和通信。这种机制既提高了端口资源的利用率,也增加了客户端运行的灵活性。
运行客户端
同样,根服务端一个道理,我们要添加IP地址和端口号成员变量。但对于一个客户端来说,我们是必须要知道服务器端的ip地址与端口号的,因此在客户端类当中引入服务端的IP地址和端口号,此时我们就可以根据传入的服务端的IP地址和端口号对对应的成员进行初始化。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpClient
{
public:UdpClient(std::string server_ip, int server_port):_sockfd(-1),_server_port(server_port),_server_ip(server_ip){}bool InitClient(){// ...}~UdpClient(){// ...}
private:int _sockfd; //文件描述符int _server_port; //服务端端口号std::string _server_ip; //服务端IP地址
};
同样跟服务器端一样,当运行起来后,我们就需要处理通信的问题了,对于客户端来说,是应该先发送数据,然后再接收到服务器端处理完后返回的数据。
那么思路就是我们将客户端也设置为死循环,设置为我们自行输入要发送的数据,然后向服务器端发送数据,然后接收到返回的数据后打印出来。按照此逻辑写代码。
void Start(){std::string msg;struct sockaddr_in peer;memset(&peer, '\0', sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(_server_port);peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());for (;;){std::cout << "Please Enter# ";getline(std::cin, msg);sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));char buffer[SIZE];struct sockaddr_in tmp;socklen_t len = sizeof(tmp);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&tmp, &len);if (size > 0){buffer[size] = '\0';std::cout << buffer << std::endl;}}}
然后我们简单的编写一下UDP客户端的.cc文件,这里用到了引入命令行参数,不了解的话,可以去搜一下,理解起来也是不难的。因为这部分的代码不是很难,所以就直接给代码,不讲解了。
#include "UdpClient.hpp"void Usage(std::string proc)
{std::cout << "\n\rUsage: " << proc << " serverip serverport\n" << std::endl;
}int main(int argc, char *argv[])
{if(argc != 3){Usage(argv[0]);exit(0);}// 同样作为客户端,我们是先发信息,然后经过服务端处理后才会返回让客户端接收到// 同样要创建套接字// int sockfd_; // 网路文件描述符// std::string ip_; // 任意 ip 地址 表示的是服务器的IP地址。具体来说,ip_ 是用来绑定服务器的网络接口的IP地址。// uint16_t port_; // 表明服务器进程的端口号std::string server_ip = argv[1];int server_port = atoi(argv[2]);UdpClient* clt = new UdpClient(server_ip, server_port);clt->InitClient();clt->Start();return 0;
}
最后添加Makefile文件
.PHONY:all
all:udpserver udpclientudpserver:main.ccg++ -o $@ $^ -std=c++11
udpclient:UdpClient.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -f udpserver udpclient
最后测试结果:
当然我上面代码的封装,其实做的不是很好,UdpServer.hpp与UdpClient.hpp代码还有很多的重复。