我自己在学习UDP服务器的时候,有着太多的不解,我不明白一个udp服务器是如何设计出来的。我在网上找了很多的资料,不过绝大多数都是把代码往哪里一放,具体的设计流程完全不提,这让我看了之后一头雾水。或许对于刚刚开始学习网络的小伙伴们来说,也面临着同样的问题。于是今天,我以自己的理解详细的给大家介绍一下udp服务器是如何一步一步的写出来的。当然,由于本人也是小白,知识水平有限,若各位大佬发现我的文章有不对的地方,希望能够及时指出我的错误。谢谢
class UdpServer
{
public:
UdpServer(int port, std::string ip)
:_port((uint16_t)port)
,_ip(ip)
, _sockfd(-1)
{}
~UdpServer()
{}
void Init()
{}
void Start()
{}
private:
//一个udp服务器至少要有以下这些成员
//1.套接字,所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。本质上,套接字就是一个文件描述符。
int _sockfd;
//2.端口号(port), 标识一台主机的具体某个进行网络通信的进程,是一个16位的无符号整数
uint16_t _port;
//3.ip,在公网上唯一标识一台主机
std::string _ip;
};
int main()
{
uint16_t port;
std::string ip;
UdpServer svr(port, ip);
svr.Init();
svr.Start();
return 0;
}
下面我们来一步步完善。
udpServer.cc编译之后会形成可执行程序udpServer,我们想运行这个服务器只需要./udpServer。今天,我们运行服务器的时候还要指明端口号和ip(ip可以省略)。以下面这种方式运行
./udpServer port [ip]
这样我们可以通过命令行参数获取到port和ip。代码就变成了这样
class UdpServer
{
public:
UdpServer(int port, std::string ip)
:_port((uint16_t)port)
,_ip(ip)
, _sockfd(-1)
{}
~UdpServer()
{}
void Init()
{}
void Start()
{}
private:
//一个udp服务器至少要有以下这些成员
//1.套接字,所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
//本质上,套接字就是一个文件描述符。
int _sockfd;
//2.端口号(port), 标识一台主机的具体某个进行网络通信的进程,是一个16位的无符号整数
uint16_t _port;
//3.ip,在公网上唯一标识一台主机
std::string _ip;
};
void Usage(const std::string proc)
{
std::cout << "Usage:" << proc << " " << "port" << " "<< "[ip]" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 2 && argc != 3)
{
//命令行参数,要么是3要么是2,其他都是错误的
Usage(argv[0]);//告诉用户使用方式
exit(1);
}
uint16_t port = atoi(argv[1]);//从命令行获取端口号,我们接收到的命令行参数是字符串,注意要将字符串转整形
std::string ip;
if(argc == 3)
{
ip = argv[2];//如果命令行参数是3个,那么第三个就是ip
}
UdpServer svr(port, ip);
svr.Init();
svr.Start();
return 0;
}
这里解释一下为什么ip可以省略呢?首先我使用的腾讯云服务器,端口号我可以自主选择开放哪个端口,而云服务器暴露给用户的ip不能绑定。如果你是自己的虚拟机,那么你填上你自己虚拟机的ip应该就能绑定。ip暂时省略,我们下面再解决。
服务器的初始化分为以下几个步骤。
创建套接字。其实就是打开一个文件。我们需要使用下面这个函数来创建套接字
#include
#include
int socket(int domain, int type, int protocol);
//domain(范围):指明是本地通信还是网络通信,我们是网络通信,这里参数传 AF_INET.
//type:套接字类型。我们udp服务器是面向数据报的,此处参数传SOCK_DGRAM
//protocol(协议):网络应用,传0就行
//返回值:文件描述符
绑定 套接字 和 ip+port
绑定我们需要用到这个函数
#include
#include
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
//sockfd:我们要绑定的套接字。
//返回值:成功返回0,失败返回-1
后两个参数里面,一定包含了ip与port信息。
为了弄清楚后两个参数的意义,我们介绍一下struct sockaddr,struct sockaddr_in
这两个结构体大小是一样的,只不过sockaddr_in具体说明了哪几位标识port,哪几位表示ip。我们可以先创建一个sockaddr_in的结构体,把port和ip信息填上去,再将其强转为sockaddr类型做为参数传到bind函数。这样就可以将套接字 和 ip+port绑定了
先填充基本信息导struct sockaddr_in.
struct sockaddr_in local;
local.sin_family = AF_INET;//网络通信,ipv4
local.sin_port = htons(_port);//port号要发送到网络当中的,hton主机转网络,s表示short,16位
local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(_ip.c_str());
这里解释一下。sin_family,协议家族,指明是本地通信还是网络通信,我们还是填AF_INET。
sin_port,端口号,我们的端口号是一个16位的无符号整数,端口号要发送到网络中,必须“主机转网络”。h表示host(主机),n表示网络,而s表示short(16位),因此我们要htons(_port)。
sin_addr.s_addr填ip信息,我们经常看到的ip比如"223.101.110.33"是点分十进制的字符串风格的ip,这种风格的ip适合给人看,但是一个ipv4的ip地址最大也就是“255.255.255.255”,只需要4字节也就是32位就足够了,因此我们在网络中使用4字节ip。ip也是要发送到网络中的,也要主机转网络。htonl,其中l表示long(32位)。如果我们的_ip原本为空,比如我的云服务器就为空,就填INADDR_ANY。含义是,让服务器端计算机上的全部网卡的IP地址均可以做为服务器IP地址,也即监听外部客户端程序发送到服务器端全部网卡的网络请求,我们绝大多数情况下使用INADDR_ANY。如果不为空,指定填充特定的ip要使用inet_addr()函数。这个函数不仅帮助我们“主机转网络”,同时还做到了字符串风格ip,转为4字节ip。
这里再讲一下什么是主机转网络,什么意思呢?
要知道,机器分为大端机(以大端字节序存储数据)和小端机(以小端字节序存储数据),假如大端机上的数据直接发送到小端机,小端机肯定看不懂(好比本应该从左往右读的句子从右往左读)。因此,数据在网络中传输的时候,提出了这样一个规定。就是网络中的数据都是以大端字节序存储,谁要是想上网,那就得按我的规矩来,你原本是大端机就直接把数据传过来,小端机你自己把数据反过来在传过来。htoi就是做这个工作的。当然还有对应的ntoh,也就是把网络中的数据转成主机的数据。
在我们填好网络信息之后我们调用bind函数绑定即可。至此,初始化函数的部分我们就写好了
void Init()
{
//1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
std::cout << "socket fail!" << std::endl;
exit(2);
}
std::cout << "socket success!" << std::endl;
//2.绑定 套接字 和 ip+port
//2.1填充基本信息到struct sockaddr_in.
struct sockaddr_in local;
local.sin_family = AF_INET;//网络通信,ipv4
local.sin_port = htons(_port);//port号要发送到网络当中的,hton主机转网络,s表示short,16位
local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(_ip.c_str());
//2.2bind网络信息
if(bind(_sockfd, (const sockaddr*)&local, sizeof(local))< 0)
{
std::cout << "bind fail!" << std::endl;
exit(3);
}
std::cout << "bind success" << std::endl;
}
void Start()
{
//服务器和客户端进行通信,也就是发送和接收数据,因此,我们给服务器定义一个发送缓冲区和接收缓冲区
char inBuffer[1024];//将来接收到的数据都放在inBuffer
char outBuffer[1024];//将来发送的数据都放在outBuffer;
while(true)//服务器都是死循环一直运行的
{
}
}
首先,我们作为服务器,肯定得从对端(客户端)接受数据,我们要用到这个函数接收数据
#include
#include
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
//sockfd:套接字,是和对端主机通信的端点,这里传我们服务器的套接字即可
//buf:接收到的数据放到哪,填inBuffer即可
//len:缓冲区长度,填sizeof(buffer)-1.
//flags:直接填0
//src_addr:我们这个函数是从对端接受消息,肯定得知道从哪里接收,得知道对端的ip,端口信息啊。我们填的前几个参数,都是本主机的信息,而这个参数,就是用于接受对方主机信息的。这是一个输出型参数,我们需要先自己定义一个struct sockaddr_in类型的变量,再将其强转(struct sockaddr*)传入。
//addrlen:这是一个输入输出型参数,填上一个参数的大小。
//返回值:从对端读取到的字节数,读取失败返回-1.
当然,服务器也要像客户端发送数据。这就要用到下面这个函数
#include
#include
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
//sockfd:套接字,是和对端主机通信的端点,这里传我们服务器的套接字即可
//buf:发什么数据,填outBuffer即可
//len:发多少数据,填strlen(outBuffer)即可
//flags:直接填0
//dest_addr:对端主机的信息。一样注意强转
addrlen:填上一个参数的大小。
//返回值:发送出去的字符数,失败返回-1.
学会这两个函数我们直接看代码
void Start()
{
//服务器都是要处理具体的业务的。今天我们写这样一个简单的业务:
//将对端发送过来的数据在服务器端打印出来(指明对端ip,port),将数据中的小写字母转为大写字母,再将数据返还客户端
//服务器和客户端进行通信,也就是发送和接收数据,因此,我们给服务器定义一个发送缓冲区和接收缓冲区
char inBuffer[1024];//将来接收到的数据都放在inBuffer
char outBuffer[1024];//将来发送的数据都放在outBuffer;
struct sockaddr_in peer;//输出型参数,将来对端主机的信息就放在这里
socklen_t len = sizeof(peer);//输入输出型参数
while(true)//服务器都是死循环一直运行的
{
ssize_t s = recvfrom(_sockfd, inBuffer, sizeof(inBuffer)-1, 0, (struct sockaddr*)&peer, &len);
if(s > 0)
{
//成功读取到数据
inBuffer[s] = '\0';
}
else
{
continue;//服务器不能因为一次数据读取失败就退出,这里直接continue
}
//读取成功的数据,我们可以提取出对端的ip和端口信息
std::string peer_ip = inet_ntoa(peer.sin_addr);//这个函数可以将ip网络转主机,并且4字节转字符串风格
uint16_t peer_port = ntohs(peer.sin_port);
std::cout << "[" <<peer_ip << ":" << peer_port<<"]# " << inBuffer << std::endl;
//小写字母转大写字母,再发给客户端
for(int i = 0; i < s; i++)
{
outBuffer[i] = inBuffer[i];
if(islower(outBuffer[i]))
{
toupper(outBuffer[i]);
}
}
sendto(_sockfd, outBuffer, strlen(outBuffer), 0, (const sockaddr*)&peer, len);
}
}
至此,我们服务器的代码写的就差不多了,我把整体代码放在下面
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
class UdpServer
{
public:
UdpServer(int port, std::string ip)
:_port((uint16_t)port)
,_ip(ip)
, _sockfd(-1)
{}
~UdpServer()
{}
void Init()
{
//1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
std::cout << "socket fail!" << std::endl;
exit(2);
}
std::cout << "socket success!" << std::endl;
//2.绑定 套接字 和 ip+port
//2.1填充基本信息到struct sockaddr_in.
struct sockaddr_in local;
local.sin_family = AF_INET;//网络通信,ipv4
local.sin_port = htons(_port);//port号要发送到网络当中的,hton主机转网络,s表示short,16位
local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(_ip.c_str());
//2.2bind网络信息
if(bind(_sockfd, (const sockaddr*)&local, sizeof(local))< 0)
{
std::cout << "bind fail!" << std::endl;
exit(3);
}
std::cout << "bind success" << std::endl;
}
void Start()
{
//服务器都是要处理具体的业务的。今天我们写这样一个简单的业务:
//将对端发送过来的数据在服务器端打印出来(指明对端ip,port),将数据中的小写字母转为大写字母,再将数据返还客户端
//服务器和客户端进行通信,也就是发送和接收数据,因此,我们给服务器定义一个发送缓冲区和接收缓冲区
char inBuffer[1024];//将来接收到的数据都放在inBuffer
char outBuffer[1024];//将来发送的数据都放在outBuffer;
struct sockaddr_in peer;//输出型参数,将来对端主机的信息就放在这里
socklen_t len = sizeof(peer);//输入输出型参数
while(true)//服务器都是死循环一直运行的
{
ssize_t s = recvfrom(_sockfd, inBuffer, sizeof(inBuffer)-1, 0, (struct sockaddr*)&peer, &len);
if(s > 0)
{
//成功读取到数据
inBuffer[s] = '\0';
}
else
{
continue;//服务器不能因为一次数据读取失败就退出,这里直接continue
}
//读取成功的数据,我们可以提取出对端的ip和端口信息
std::string peer_ip = inet_ntoa(peer.sin_addr);//这个函数可以将ip网络转主机,并且4字节转字符串风格
uint16_t peer_port = ntohs(peer.sin_port);
std::cout << "[" <<peer_ip << ":" << peer_port<<"]# " << inBuffer << std::endl;
//小写字母转大写字母,再发给客户端
for(int i = 0; i < s; i++)
{
outBuffer[i] = inBuffer[i];
if(islower(outBuffer[i]))
{
toupper(outBuffer[i]);
}
}
sendto(_sockfd, outBuffer, strlen(outBuffer), 0, (const sockaddr*)&peer, len);
}
}
private:
//一个udp服务器至少要有以下这些成员
//1.套接字,所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
//本质上,套接字就是一个文件描述符。
int _sockfd;
//2.端口号(port), 标识一台主机的具体某个进行网络通信的进程,是一个16位的无符号整数
uint16_t _port;
//3.ip,在公网上唯一标识一台主机
std::string _ip;
};
void Usage(const std::string proc)
{
std::cout << "Usage:" << proc << " " << "port" << " "<< "[ip]" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 2 && argc != 3)
{
//命令行参数,要么是3要么是2,其他都是错误的
Usage(argv[0]);//告诉用户使用方式
exit(1);
}
uint16_t port = atoi(argv[1]);//从命令行获取端口号,我们接收到的命令行参数是字符串,注意要将字符串转整形
std::string ip;
if(argc == 3)
{
ip = argv[2];//如果命令行参数是3个,那么第三个就是ip
}
UdpServer svr(port, ip);
svr.Init();
svr.Start();
return 0;
}
写完服务器,我们也要实现对应的客户端与之通信。有了上面的基础,现在写客服端非常简单!
class UdpClient
{
public:
UdpClient()
:_sockfd(-1)
{}
~UdpClient()
{}
void Init()
{}
void start()
{}
private:
int _sockfd;
};
int main()
{
UdpClient client;
client.Init();
client.start();
return 0;
}
udpClient.cc编译之后会形成可执行程序udpClient,我们想运行这个客户端只需要./udpClient。今天,我们运行服务器的时候还要指明访问的ip和端口号以下面这种方式运行
./udpClient ip port
这样我们可以通过命令行参数获取到port和ip。代码就变成了这样
class UdpClient
{
public:
UdpClient()
:_sockfd(-1)
{}
~UdpClient()
{}
void Init()
{}
void start()
{}
private:
int _sockfd;
};
void Usage(char* proc)
{
std::cout << "Usage: " << proc << " ip port" << std::endl;
}
// ./udpClient ip port
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
//从命令行获取服务器的ip和端口号
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
UdpClient client;
client.Init();
client.start();
return 0;
}
void Init()
{
//1.创建socket套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
std::cout << "socket fail!" << std::endl;
exit(2);
}
std::cout << "socket success!" << std::endl;
//2.bind
//客户端需不需要bind?
//需要,但是不需要用户手动bind,而是OS自动给你bind,如果你手动bind指定的port号,可能会失败
//因为你的主机上有很多的client进程在运行,你指定的port可能被别的客户端bind了。
//如果你非要手动bind,也不是不可以,但是,这种做法及其不推荐
//那服务器为什么要bind呢?
//要知道一个服务器要给许许多多的客户端提供服务的,服务器的端口号,必须是固定的。
}
void start(std::string& server_ip, uint16_t server_port)
{
//3这里就是通信过程了。
//同样的,我们也定义一个inBuffer和outBuffer
char inBuffer[1024];
std::string outBuffer;
while(true)
{
std::cout << "Please Enter# ";
//3.1 先把数据写到outBuffer
std::getline(std::cin, outBuffer);
//3.2把数据发给服务器,我们同样需要用到sendto函数
//当然啦,我们首先得把对端的网络信息填好才行
struct sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_port = htons(server_port);
peer.sin_addr.s_addr = inet_addr(server_ip.c_str());
//OS自动绑定ip+port就发生在第一次sendto的时候
sendto(_sockfd, outBuffer.c_str(), outBuffer.size(), 0, (const sockaddr*)&peer, sizeof(peer));
//3.3接下来要从服务器端接受消息,还是使用recvfrom
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, inBuffer, sizeof(inBuffer)-1, 0, (struct sockaddr*)&peer, &len);
if(s > 0)
{
//成功接收
inBuffer[s] = 0;
std::cout << "server echo# " << inBuffer << std::endl;
}
else
{
//服务器无回应
std::cout << "recvfrom server nothing!" << std::endl;
}
}
}
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
class UdpClient
{
public:
UdpClient()
:_sockfd(-1)
{}
~UdpClient()
{}
void Init()
{
//1.创建socket套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
std::cout << "socket fail!" << std::endl;
exit(2);
}
std::cout << "socket success!" << std::endl;
//2.bind
//客户端需不需要bind?
//需要,但是不需要用户手动bind,而是OS自动给你bind,如果你手动bind指定的port号,可能会失败
//因为你的主机上有很多的client进程在运行,你指定的port可能被别的客户端bind了。
//如果你非要手动bind,也不是不可以,但是,这种做法及其不推荐
//那服务器为什么要bind呢?
//要知道一个服务器要给许许多多的客户端提供服务的,服务器的端口号,必须是固定的。
}
void start(std::string& server_ip, uint16_t server_port)
{
//3这里就是通信过程了。
//同样的,我们也定义一个inBuffer和outBuffer
char inBuffer[1024];
std::string outBuffer;
while(true)
{
std::cout << "Please Enter# ";
//3.1 先把数据写到outBuffer
std::getline(std::cin, outBuffer);
//3.2把数据发给服务器,我们同样需要用到sendto函数
//当然啦,我们首先得把对端的网络信息填好才行
struct sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_port = htons(server_port);
peer.sin_addr.s_addr = inet_addr(server_ip.c_str());
//OS自动绑定ip+port就发生在第一次sendto的时候
sendto(_sockfd, outBuffer.c_str(), outBuffer.size(), 0, (const sockaddr*)&peer, sizeof(peer));
//3.3接下来要从服务器端接受消息,还是使用recvfrom
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, inBuffer, sizeof(inBuffer)-1, 0, (struct sockaddr*)&peer, &len);
if(s > 0)
{
//成功接收
inBuffer[s] = 0;
std::cout << "server echo# " << inBuffer << std::endl;
}
else
{
//服务器无回应
std::cout << "recvfrom server nothing!" << std::endl;
}
}
}
private:
int _sockfd;
};
void Usage(char* proc)
{
std::cout << "Usage: " << proc << " ip port" << std::endl;
}
// ./udpClient ip port
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
//从命令行获取服务器的ip和端口号
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
UdpClient client;
client.Init();
client.start(server_ip, server_port);
return 0;
}
至此我们代码终于写完了,让我们测试一下看看效果吧
注意:由于我只有一台云服务器,我测试的时候使用的是“127.0.0.1”本地环回。
简单说就是数据在同一个协议栈自上而下,再自下而上传递。
当然了,如果你想在你的windows下访问服务器,就需要你在windows环境下自己写一个客户端了。写起来也是大同小异的,感兴趣的同学请自行搜索一下。有了我这篇文章做铺垫,相信你能够在windows下轻松实现。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-im2mEc11-1677480151646)(https://lin-typora.oss-cn-beijing.aliyuncs.com/image-20230227143057868.png)]
感谢各位大佬的观看。期待您的支持!