【Linux网络编程】第三弹---UDP网络通信深度解析:构建服务器端、客户端,并实现两端通信的完整步骤与测试
✨个人主页: 熬夜学编程的小林
💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】
目录
1、服务器端
1.1、主函数
1.2、UdpServer类
1.2.1、基本结构
1.2.2、构造析构函数
1.2.3、InitServer()
1.2.4、Start()
2、客户端
2.1、UdpClient
2.1.1、五个步骤
2.1.2、两个注意
2.1.3、代码实现
3、两端通信
3.1、UdpServer
3.1.1、测试一(固定版本)
3.1.2、测试二(传端口和IP版本)
3.1.3、测试三(优化网络转换)
上一弹我们讲解了socket编程的基本知识,此弹设计一个基于UDP协议的网络编程代码,能够简单的回显服务器和客户端代码!!!
我们需要做到服务器与客户端进行通信,需要先分别实现服务器和客户端的代码,我们依旧使用先写主函数,再写类对象的方式实现!
1、服务器端
1.1、主函数
主函数通过智能指针构造Server类,并初始化和启动服务!
注意:此处需要用到前面实现的日志类,只需将日志类文件拷贝过来即可!
int main()
{
EnableScreen();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(); // C++14标准
usvr->InitServer(); // 初始化服务端
usvr->Start(); // 启动服务端
return 0;
}
1.2、UdpServer类
表示错误的枚举类型
enum
{
SOCKET_ERROR = 1,
BIND_ERROR
};
全局变量
static const int gsockfd = -1;
static const uint16_t glocalport = 8888;
UdpServer类成员变量需要文件描述符,IP,端口,运行状态;初始化函数创建socket套接字,并将套接字进行绑定;启动函数收客户端的消息并回复客户端!
1.2.1、基本结构
class UdpServer
{
public:
UdpServer(uint16_t localport = glocalport);
// 初始化
void InitServer();
// 启动
void Start();
~UdpServer();
private:
int _sockfd; // 文件描述符
uint16_t _localport; // 端口号
std::string _localip; // ip地址,TODO后面处理
bool _isrunning;
};
1.2.2、构造析构函数
构造函数初始化_sockfd,_localport(默认初始化为全局端口号),_localip(传参),_isrunning(默认为false);析构函数关闭文件(前提是创建了文件)即可!
// 构造函数(可以不传参版本)
UdpServer(uint16_t localport = glocalport)
: _sockfd(gsockfd), _localport(localport),_isrunning(false)
{
}
// 构造函数(需要传参版本)
UdpServer(const std::string &localip, uint16_t localport = glocalport)
: _sockfd(gsockfd), _localport(localport), _localip(localip), _isrunning(false)
{
}
// 析构函数
~UdpServer()
{
// 关闭文件
if(_sockfd < gsockfd) ::close(_sockfd);
}
1.2.3、InitServer()
初始化函数创建socket套接字,并将套接字进行绑定;
1、此处先测试一下socket()函数的返回值,创建成功正常会返回3,因为0,1,2,已经被占用了!
// 测试
void InitServer()
{
_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error\n");
exit(SOCKET_ERROR);
}
LOG(DEBUG, "socket create success,sockfd: %d\n", _sockfd); // 3
}
注意:此处的退出码用到了枚举类型,在UdpServer类最前面有具体代码!!!
Start()
此处为了先测试,有Start()函数即可,保证能够编译通过,后面再实现函数!
void Start()
{}
运行结果
为了防止Server类被拷贝,此处可以设计一个防止拷贝的类,并让Server类继承,此时Server类就不能被拷贝和赋值了!!!
nocopy类
class nocopy
{
public:
nocopy(){}
~nocopy(){}
nocopy(const nocopy&) = delete;
const nocopy& operator=(const nocopy&) = delete;
};
UdpServer类
// 继承不能拷贝的类
class UdpServer : public nocopy
{
public:
// ...
private:
// ...
}
主函数
int main()
{
UdpServer user1;
UdpServer user2 = user1; // 禁止赋值
UdpServer user3(user1); // 禁止拷贝
return 0;
}
运行结果
2、将套接字进行绑定
套接字绑定即 将文件描述符与 网络序列的端口号和IP绑定!
需要用到IP转换函数
inet_addr()
将一个点分十进制的IPv4地址(例如 "192.168.1.1")转换为一个网络字节序(通常是大端序)的整数
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
参数:
in_addr_t
:函数返回类型,表示转换后的IPv4地址。inet_addr
:函数名。const char *cp
:函数的参数,是一个指向以null结尾的字符串的指针,该字符串表示一个点分十进制的IPv4地址。
返回值:
- 如果输入字符串是一个有效的IPv4地址字符串,函数返回转换后的网络字节序整数。
- 如果输入字符串不是一个有效的IPv4地址字符串,函数返回
INADDR_NONE
,这是一个特殊的常量,通常定义为-1
,用于指示错误。
InitServer()
void InitServer()
{
// 1.创建socket文件
_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error\n");
exit(SOCKET_ERROR);
}
LOG(DEBUG, "socket create success,sockfd: %d\n", _sockfd); // 3
// 2.bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_localport); // 主机序列转网络序列
local.sin_addr.s_addr = inet_addr(_localip.c_str()); // 1.需要4字节ip 2.需要网络序列ip
int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error\n");
exit(BIND_ERROR);
}
LOG(DEBUG, "socket bind success\n");
}
1.2.4、Start()
启动函数收客户端的消息并回复客户端(此处是一个死循环[根据常识])!
接收消息函数
recvfrom()
从套接字接收数据。
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数:
- sockfd:已经创建并绑定的套接字的文件描述符。
- buf:指向用于存储接收到的数据的缓冲区的指针。
- len:缓冲区的大小,以字节为单位。
- flags:接收操作的标志(此处设置为0即可),用于修改
recvfrom
的行为。常用的标志包括MSG_DONTWAIT
(非阻塞模式)和MSG_WAITALL
(阻塞模式,直到接收到指定大小的数据)。 - src_addr:指向
sockaddr
结构体的指针,用于存储发送方的地址信息。 - addrlen:指向整型的指针,用于指定
src_addr
结构体的大小,并在调用后被设置为新接收到的地址的实际大小。
返回值:
recvfrom
成功时返回接收到的字节数- 失败时返回-1,并设置全局变量
errno
来指示错误的原因。
接收消息是从网络里面接收,我们需要将网络的端口号和IP转成主机序列!!
网络转主机函数
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
将网络字节序(通常是 IPv4 地址)转换为点分十进制字符串(即我们常见的 "x.x.x.x" 格式的 IP 地址)的函数 。
参数:
in
:一个struct in_addr
结构体,它包含一个uint32_t
类型的成员s_addr
,该成员以网络字节序(大端序)存储 IPv4 地址。
返回值:
- 成功时,返回一个指向静态分配的、表示点分十进制 IP 地址字符串的指针。这个字符串不应该被修改或释放。
- 失败时,返回 nullptr(但实际上,由于
inet_ntoa
只是一个简单的转换函数,它几乎总是能成功,除非传入的struct in_addr
结构无效)。
发送消息函数
sendto()
向套接字发送消息。
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
sockfd
:套接字描述符,是通过socket
函数创建的套接字文件描述符。buf
:指向要发送数据的缓冲区。len
:要发送数据的字节数。flags
:用于控制发送行为的标志位,通常设置为 0,但也可以使用以下选项之一或多个(使用按位或运算符|
组合):MSG_CONFIRM
:请求确认消息数据已被发送(适用于某些特定协议)。MSG_DONTROUTE
:绕过路由表,直接发送数据(仅适用于某些协议)。MSG_EOR
:表示数据记录的结束(对于某些流协议可能有用)。MSG_MORE
:指示后续将发送更多数据(对于某些协议,可能会优化发送)。MSG_NOSIGNAL
:防止发送过程中产生 SIGPIPE 信号(如果连接已经关闭)。
dest_addr
:指向目标地址的指针,通常是一个struct sockaddr_in
(用于 IPv4)或struct sockaddr_in6
(用于 IPv6)结构体。addrlen
:目标地址的长度,通常是sizeof(struct sockaddr_in)
或sizeof(struct sockaddr_in6)
。
返回值:
- 成功时,返回发送的字节数。
- 失败时,返回 -1,并设置
errno
以指示错误类型。
void Start()
{
_isrunning = true;
char inbuffer[1024];
while (_isrunning)
{
// sleep(1);
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 收消息,返回值: 实际收到多少字节
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
// uint16_t peerport = ntohs(peer.sin_port); // 网络转主机
// std::string peerip = inet_ntoa(peer.sin_addr); // 结构化转字符串
inbuffer[n] = 0;
std::cout << "client say# " << inbuffer << std::endl;
// std::cout << "[" << peerip << ":" << peerport << "]# " << inbuffer << std::endl;
std::string echo_string = "[udp_server echo] #";
echo_string += inbuffer;
// 发消息
sendto(_sockfd,echo_string.c_str(),echo_string.size(),0,(struct sockaddr *)&peer,len);
}
}
}
2、客户端
2.1、UdpClient
Client端先向服务器发送消息(以行读取并发送),然后接收服务器的消息!
注意:我们向服务器发送消息需要知道服务器的端口和IP,因此此处使用命令行确定端口和IP!
使用形式:
// 客户端在未来一定要知道服务器的IP地址和端口号
// .udp_client server-ip server-port
// .udp_client 127.0.0.1 8888
2.1.1、五个步骤
客户端程序分为5步:
0.读取接收端IP和端口
1.创建套接字
2.设置接收端信息
3.发消息和接收消息
4.关闭套接字
2.1.2、两个注意
注意:
1、客户端的绑定和服务端有一些区别!!
client的端口号,一般不让用户自己设定,而是让client 所在OS随机选择?怎么选择?什么时候?
1、client 需要bind它自己的IP和端口,但是client 不需要 "显示" bind它自己的IP和端口
2、client 在首次向服务器发送数据的时候,OS会自动给client bind它自己的IP和端口
2、设置接收端信息需要转换!
inet_addr()
将一个用点分十进制(例如“a.b.c.d”格式)表示的IP地址转换成一个长整型数(在C语言中通常为
unsigned long
或u_long
类型),这个长整型数在网络编程中代表该IP地址的网络字节序二进制值。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
2.1.3、代码实现
// 客户端在未来一定要知道服务器的IP地址和端口号
// .udp_client server-ip server-port
// .udp_client 127.0.0.1 8888
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server-ip server-port" << std::endl;
exit(0);
}
// 0.读取接收端IP和端口
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1.创建套接字
int sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "create socket eror\n" << std::endl;
exit(1);
}
// client的端口号,一般不让用户自己设定,而是让client 所在OS随机选择?怎么选择?什么时候?
// client 需要bind它自己的IP和端口,但是client 不需要 "显示" bind它自己的IP和端口
// client 在首次向服务器发送数据的时候,OS会自动给client bind它自己的IP和端口
// 2.设置接收端信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); // 转换重要!!!
server.sin_addr.s_addr = inet_addr(serverip.c_str());
// 3.发消息和接收消息
while (true)
{
std::string line;
std::cout << "Please Enter# ";
std::getline(std::cin, line); // 以行读取消息
// 发消息,你要知道发送给谁
int n = sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr *)&server, sizeof(server));
if(n > 0)
{
// 收消息
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
char buffer[1024];
int m = recvfrom(sockfd,buffer,sizeof(buffer),0,(struct sockaddr*)&temp,&len);
if(m > 0)
{
buffer[m] = 0;
std::cout << buffer << std::endl;
}
else
{
break;
}
}
else
{
break;
}
}
// 4.关闭套接字
::close(sockfd);
return 0;
}
3、两端通信
3.1、UdpServer
服务器先接受客户端的消息,再发消息给客户端!
3.1.1、测试一(固定版本)
第一个测试先让客户端与服务端进行简单的通信,因此需要先设置服务器端的IP和端口号(此处把IP和端口号固定了,因此对这个IP和端口号才能正常通信)。
主函数
int main()
{
uint16_t port = 8899;
std::string ip = "127.0.0.1";
EnableScreen();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip,port); // C++14标准
usvr->InitServer();
usvr->Start();
return 0;
}
运行结果
这个版本还有一个小问题: 每次都是固定的字符串,并不知道谁发送过来的消息!!!
可以优化一下Server类的Start()函数!!!
在recvfrom()接收消息的函数中,后面有两个输出型参数,代表的是发送者的信息,我们可以读取到发送者的IP 和 端口号,但是那是网络层面的,在本地读取需要转换!!!
inet_ntoa()
// 将一个 32 位的网络字节序的 IP 地址转换为点分十进制的 IP 地址字符串
char *inet_ntoa(struct in_addr in);
优化
void Start()
{
_isrunning = true;
char inbuffer[1024];
while (_isrunning)
{
// sleep(1);
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 收消息,返回值: 实际收到多少字节
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
uint16_t peerport = ntohs(peer.sin_port); // 网络转主机
std::string peerip = inet_ntoa(peer.sin_addr); // 结构化转字符串
inbuffer[n] = 0;
// std::cout << "client say# " << inbuffer << std::endl;
std::cout << "[" << peerip << ":" << peerport << "]# " << inbuffer << std::endl;
std::string echo_string = "[udp_server echo] #";
echo_string += inbuffer;
// 发消息
sendto(_sockfd,echo_string.c_str(),echo_string.size(),0,(struct sockaddr *)&peer,len);
}
}
}
运行结果
3.1.2、测试二(传端口和IP版本)
在第一个测试中,服务端只要启动程序就只能收到固定IP + 端口的信息(缺点),但是我们并不是每次都是固定的,我们也可以灵活一点,此处可以引入命令行参数!
主函数
// .udp_client local-ip local-port
// .udp_client 127.0.0.1 8888
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " local-ip server-port" << std::endl;
exit(0);
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
EnableScreen();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip,port); // C++14标准
usvr->InitServer();
usvr->Start();
return 0;
}
运行结果
此处还可以进行优化,如果我们有了固定IP + 端口这样的方式,那么只有一个网络进程可以发送成功,我们此处可以将服务器的IP设置为0,那么只要端口一样,服务器端就能收到客户端的消息!
优化
需要删除Server类的_localip成员变量,修改构造函数,无需初始化_localip,InitServer()函数时将IP设置为INADDR_ANY,并修改主函数,只需要传两个参数。
Server类
class UdpServer : public nocopy
{
public:
UdpServer(uint16_t localport = glocalport)
: _sockfd(gsockfd), _localport(localport), _isrunning(false)
{
}
void InitServer()
{
// 1.创建socket文件
_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error\n");
exit(SOCKET_ERROR);
}
LOG(DEBUG, "socket create success,sockfd: %d\n", _sockfd); // 3
// 2.bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_localport); // 主机序列转网络序列
// local.sin_addr.s_addr = inet_addr(_localip.c_str()); // 1.需要4字节ip 2.需要网络序列ip
local.sin_addr.s_addr = INADDR_ANY; // 服务器端,进行任意IP地址绑定[0]
int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error\n");
exit(BIND_ERROR);
}
LOG(DEBUG, "socket bind success\n");
}
private:
int _sockfd; // 文件描述符
uint16_t _localport; // 端口号
// std::string _localip; // ip地址,TODO后面处理
bool _isrunning;
};
主函数
// .udp_client local-port
// .udp_client 8888
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " server-port" << std::endl;
exit(0);
}
uint16_t port = std::stoi(argv[1]);
EnableScreen();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port); // C++14标准
usvr->InitServer();
usvr->Start();
return 0;
}
运行结果
3.1.3、测试三(优化网络转换)
在服务端,为了打印客户端的IP和端口,我们需要使用转换函数,但是此处还是属于面向过程编程,此处我们可以封装成转换的类,调用该类则自动转换,想要主机的IP和端口调用成员函数即可,此时为面向对象编程!!!
InetAddr类
该类成员需要本地的IP和端口,网络的结构体对象,构造函数将网络数据转成主机数据,并实现获取IP和端口的成员函数!
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
class InetAddr
{
private:
// 网络地址转本地地址
void ToHost(const struct sockaddr_in& addr)
{
_port = ntohs(addr.sin_port); // 网络转主机
_ip = inet_ntoa(addr.sin_addr); // 结构化转字符串
}
public:
InetAddr(const struct sockaddr_in& addr):_addr(addr)
{
ToHost(addr);
}
std::string Ip()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
~InetAddr()
{}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
Start()
void Start()
{
_isrunning = true;
char inbuffer[1024];
while (_isrunning)
{
// sleep(1);
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 收消息,返回值: 实际收到多少字节
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
// uint16_t peerport = ntohs(peer.sin_port); // 网络转主机
// std::string peerip = inet_ntoa(peer.sin_addr); // 结构化转字符串
InetAddr addr(peer);
inbuffer[n] = 0;
// std::cout << "[" << peerip << ":" << peerport << "]# " << inbuffer << std::endl;
std::cout << "[" << addr.Ip() << ":" << addr.Port() << "]# " << inbuffer << std::endl;
std::string echo_string = "[udp_server echo] #";
echo_string += inbuffer;
// 发消息
sendto(_sockfd,echo_string.c_str(),echo_string.size(),0,(struct sockaddr *)&peer,len);
}
}
}
运行结果