UDP编程接口基本使用
本篇介绍
在前面网络基础部分已经介绍了网络的基本工作模式,有了这些理论基础之后,下面先从UDP编程开始从操作部分深入网络
在本篇中,主要考虑下面的内容:
- 创建并封装服务端:了解创建服务端的基本步骤
- 创建并封装客户端,测试客户端和服务端通信:了解创建客户端的基本步骤和二者通信
- 测试云服务器与本地进行通信:从本地通信到真正实现网络通信
根据上面的内容,本次设计的服务器功能就是接受客户端发送的信息并向客户端返回服务端收到的信息
创建并封装服务端
创建服务器类
因为需要对服务器进行封装,所以首先创建服务器类的基本框架,本次设计的服务器一旦启动就不再关闭,除非手动关闭,所以可以提供两个接口:
start
:启动服务器接口stop
:停止服务器接口
所以基本框架如下:
class UdpServer
{
public:
UdpServer()
{
}
// 启动服务器
void start()
{
}
// 停止服务器
void stop()
{
}
~UdpServer()
{
}
private:
};
创建服务器套接字
既然要创建服务器,首先就是对服务器的相关信息进行设置。首先需要创建socket文件描述,可以使用socket
接口,该接口原型如下:
int socket(int domain, int type, int protocol);
该接口的第一个参数表示网络协议家族,可以选择的选择选项有很多,其中包括AF_UNIX
和AF_INET
,因为本次是网络通信,所以该参数选择AF_INET
,第二个参数表示协议类型,在网络通信部分分为两种:TCP和UDP,对应的值分别为SOCK_STREAM
和SOCK_DGRAM
,因为本次是UDP,所以选择SOCK_DGRM
根据Linux操作手册的描述:
SOCK_STREAM
:Provides sequenced, reliable, two-way, connection-based byte streams(提供序列化的、可靠的、双工的、面向有连接的字节流)SOCK_DGRAM
:Supports datagrams (connectionless, unreliable messages of a fixed maximum length)(支持数据包,即无连接、不可靠的固定长度信息)
全双工、半双工和单工是描述通信双方在数据传输时的交互模式,具体对比如下:
- 全双工(Full Duplex):双方可以同时互相发送和接收数据,就像电话通话两端都能同时说话和听对方
- 半双工(Half Duplex):双方均可发送和接收数据,但同一时间只能有一方传输。例如,对讲机通信时,一旦你在讲话,另一方必须等待直到你停止后才能回应
- 单工(Simplex):数据只能单向传输,通信只有一端发送,而另一端只接收。例如,广播视频信号中,电视台只能发送信号,观众只能接收信号
第三个参数表示指定采用的具体协议。通常传入0表示让系统自动选择适合domain
和type
参数的默认协议
该接口返回值为一个新套接字的文件描述符,否则返回-1并设置错误码
根据这个接口的描述可以知道当前服务器类需要一个成员_socketfd
用于接收socket
的返回值,代码如下:
class UdpServer
{
public:
UdpServer()
: _socketfd(-1)
{
// 创建服务器套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
}
// ...
private:
int _socketfd; // 套接字文件描述符
};
在创建服务器套接字失败时可以考虑使用日志系统显示相关的信息,一旦服务器创建异常,说明此时服务器无法正常创建,可以直接退出函数,为了保证可读性,可以将错误码定义为宏,代码如下:
// 错误码枚举类
enum class errorNumber
{
ServerSocketFail = 1, // 创建套接字失败
};
class UdpServer
{
public:
UdpServer()
: _socketfd(-1)
{
// 创建服务器套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{
LOG(LogLevel::FATAL) << "服务器启动异常:" << strerror(errno);
exit(static_cast<int>(errorNumber::ServerSocketFail));
}
LOG(LogLevel::INFO) << "服务器启动成功:" << _socketfd;
}
// ...
private:
int _socketfd; // 套接字文件描述符
};
绑定服务器IP地址和端口
前面的过程只是创建了一个可以写入的位置,socket
接口可以类比文件部分的open
接口,在网络部分接下来的步骤并不是写入,而应该是绑定端口和IP地址,确保其他计算机可以找到当前服务器和具体进程。在Linux中,绑定可以使用bind
接口,该接口原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
该接口的第一个参数表示需要绑定的套接字对应的文件描述符,第二个参数表示套接字结构,第三个参数表示套接字结构的大小
如果绑定成功,该接口返回0,否则返回-1并设置错误码
对于第一个参数和第三个参数来说,二者作用和含义明显,此处不作过多介绍,下面就第二个参数详细介绍:
在[Socket编程基础]部分提到sockaddr
可以理解为sockaddr_in
结构和sockaddr_un
的父类,而因为本次创建的是网络通信,所以要使用的结构就是sockaddr_in
,既然参数部分是sockaddr
结构而不是sockaddr_in
,那么在传递实参时就需要进行强制类型转换
那么,既然需要用户传递sockaddr_in
结构,那么这个结构中就存在一些属性需要用户去设置。在[Socket编程基础]部分的示意图已经了解到sockaddr_in
有下面的几种成员:
- 16位地址类型:用于区分当前是何种类型的通信,对应的成员名是
sin_family
- 16位端口号:对应的成员名是
sin_port
- 32位IP地址:对应的成员名是
sin_addr
- 8字节填充
因为第四个成员可以不需要考虑,只是用于占位,所以可以忽略,下面就前面三种类型进行详细介绍:
首先是16为地址类型,其类型是sa_family_t
。在底层,该类型是一个宏:
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
// ...
};
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
typedef unsigned short int sa_family_t;
这个宏利用到了[C语言的##
运算符],其含义是将sa_prefix##替换为宏传递的值,因为在__SOCKADDR_COMMON (sin_)
设置sa_prefix
对应的sin_
,所以拼接后为sin_family
,因为sa_family_t
代表的是unsigned short int
,所以sin_family
就是一个unsigned short int
的值
因为当前是网络通信,所以对应值就是AF_INET
,但是需要注意,这里传递的AF_INET
和前面在socket
接口传递的AF_INET
含义不同,在使用socket
时,指定AF_INET
表明了socket
所使用的协议族,它决定了内部数据结构和通信规则,而在调用bind
时,指定AF_INET
是因为需要通过这个成员来正确解析后续的地址信息
第二个成员是16位端口号,对于端口来说,其类型是in_port_t
。在底层,对应源码如下:
struct sockaddr_in
{
// ...
in_port_t sin_port; /* Port number. */
// ...
};
typedef uint16_t in_port_t;
typedef __uint16_t uint16_t;
typedef unsigned short int __uint16_t;
所以,本质in_port_t
也是unsigned short int
,但是因为使用unsigned short int
和__uint16_t
都不够简单,所以直接使用uint16_t
需要注意的是,在[Socket编程基础]提到过网络字节流时使用的都是大端,所以如果当前服务器是小端存储,那么就需要转换,否则就不需要转换。这里有两种处理方式:
- 判断当前设备是否是大端,如果是就直接写端口号,否则就需要对端口号进行小端到大端的转化,具体判断方式参考[进制转换与类型在内存的存储方式]
- 不论是大端还是小端都进行转换,如果是大端就不变,否则就变成大端
本次考虑第二种处理方式,系统提供了相关的接口处理大小端转换问题,如下:
uint16_t htons(uint16_t hostshort);
最后考虑第三个成员:IP地址,其类型是一个结构体:struct in_addr
,其原型如下:
struct sockaddr_in
{
// ...
struct in_addr sin_addr; /* Internet address. */
// ...
};
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
typedef __uint32_t uint32_t;
typedef unsigned int __uint32_t;
实际上就是一个结构体包含了一个unsigned int
类型的成员,所以在底层,IP地址是一个无符号整数,在设置IP地址时,需要具体指定到sin_addr
在底层,第四个成员如下:
struct sockaddr_in
{
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr)
- __SOCKADDR_COMMON_SIZE
- sizeof (in_port_t)
- sizeof (struct in_addr)];
};
以上就是bind
接口的第二个参数的详细介绍,下面根据上面的介绍对指定的套接字进行绑定:
因为需要创建端口号和IP地址,所以需要增加两个成员,其中端口号使用的类型是uint16_t
,而IP地址是字符串类型,之所以使用字符串是为了在使用时更方便,但是如果使用字符串,字符串使用的格式是点分十进制,而需要的是一个无符号整型的整数,此时就需要进行转换,并且还需要将IP地址也转换为大端字节序,这个问题对应地解决方案是inet_addr
接口,其原型如下:
in_addr_t inet_addr(const char *cp);
这个接口可以将指定的点分十进制字符串格式的IP地址转换为in_addr_t
类型,并且转换为大端字节序
接着对创建的端口号成员和IP地址成员进行初始化,本次可以考虑给定一个默认的值:8080
和127.0.0.1
,也可以让用户在创建服务器时自行设定
在构造函数内部,首先就是创建一个struct sockaddr_in
结构的对象,接着就是根据前面的提示对结构体成员进行填充,因为结构体对象一旦创建就不能再通过整体赋值的方式初始化,只能通过对每一个成员单独赋值初始化。但是填充具体值之前,建议将struct sockaddr_in
进行清空操作(即全部初始化为0),下面有两种方法:
- 使用
memset
接口进行清空 - 使用
bzero
接口进行清空
本次考虑使用bzero
接口进行,其原型如下:
#include <strings.h>
void bzero(void *s, size_t n);
这个接口和
memset
接口的效果相同
清空后就是对每个成员进行赋值初始化
初始化struct sockaddr_in
对象后,就可以对指定套接字的对应文件描述符进行绑定。同样,考虑使用日志系统显示相关信息
综上,代码如下:
// 错误码枚举类
enum class ErrorNumber
{
// ...
BindSocketFail, // 绑定失败
};
// 默认端口和IP地址
const std::string default_ip = "127.0.0.1";
const uint16_t default_port = 8080;
class UdpServer
{
public:
UdpServer(const std::string &ip = default_ip, uint16_t port = default_port)
: // ...
, _ip(ip), _port(port)
{
// ...
// 绑定端口号和IP地址
struct sockaddr_in saddrIn;
saddrIn.sin_family = AF_INET;
saddrIn.sin_port = htons(_port);
saddrIn.sin_addr.s_addr = inet_addr(_ip.c_str());
// 使用reinterpret_cast强制类型转换
int ret = bind(_socketfd, reinterpret_cast<const sockaddr *>(&saddrIn), sizeof(sockaddr_in));
if (ret < 0)
{
LOG(LogLevel::FATAL) << "Bind error" << strerror(errno);
exit(static_cast<int>(ErrorNumber::BindSocketFail));
}
LOG(LogLevel::INFO) << "Bind success";
}
// ...
private:
// ...
uint16_t _port; // 端口号
std::string _ip; // 点分十进制IP地址
};
至此,服务器创建完成,总结一下创建服务器一共分为两步:
- 创建服务器套接字对应的文件描述符
- 根据套接字对应的文件描述符进行协议家族、端口号和IP地址进行绑定
启动服务器
启动服务器就需要用到一个变量标记当前服务器是否已经启动,所以需要一个成员_isRunning
,该变量初始化为false
,如果当前服务器并没有启动,就可以启动服务器,否则就不需要启动
所谓的启动服务器就是让服务器执行指定的任务,本次服务端就是负责接收信息并回复客户端发送的信息
因为服务器一般情况下一旦启动就不会再关闭,为了模拟这种情况,考虑服务器启动后就是一个死循环,在这个循环内部就是服务器执行任务的逻辑,所以基本结构如下:
// 启动服务器
void start()
{
if (!_isRunning)
{
_isRunning = true;
while (true)
{
}
}
}
接下来就是考虑服务器执行的任务:接收客户端的消息并返回客户端的消息。对于这个任务可以拆为两个任务:
- 接收客户端消息
- 返回客户端接收到的消息
首先考虑接收客户端消息,接收客户端消息可以使用recvfrom
接口,其原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
如果仔细观察上面的接口可以发现其和之前文件部分的read
接口很类似,该接口的第一个参数表示用于接收的套接字对应的文件描述符,第二个参数表示缓冲区,用于存储接收到的数据,第三个参数表示缓冲区的大小,第四个参数表示是否是一个标记位,传递0表示使用默认的阻塞模式和行为,第五个参数表示客户端的套接字结构,第六个参数表示客户端套接字结构的大小
对于前四个参数都很好理解,关键是第五个参数,为什么服务器端接收还需要知道客户端套接字结构,具体来说,为什么服务器端接收需要知道客户端的端口和IP地址。最简单的解释就是因为UDP是无连接协议,服务器没有固定的连接信息,所以每次收到数据包时,需要知道数据包的来源(客户端的IP和端口),以便在需要回复数据时能正确定位到发送方,另外获取客户端信息有助于日志记录、错误排查以及实时监控,这样可以更准确地定位是哪个客户端发来了数据以及可能出现的问题所在
但是,需要注意,后两个参数是输出型参数,也就是说,第四个参数和第五个参数的值并不需要用户指定
该接口返回读取到的字节数,否则返回-1
根据这个接口的介绍,可以设计第一个任务的逻辑如下,同样考虑结合日志显示相应的信息:
建议接收数据时留下一个位置用于存放
\0
// 启动服务器
void start()
{
if (!_isRunning)
{
_isRunning = true;
while (true)
{
// 1. 接收客户端信息
char buffer[1024] = {0};
struct sockaddr_in peer;
socklen_t length = sizeof(peer);
// sizeof(buffer) - 1留下一个位置存放\0
ssize_t ret = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, reinterpret_cast<struct sockaddr *>(&peer), &length);
if (ret > 0)
{
struct sockaddr_in temp = static_cast<struct sockaddr_in>(peer);
// 1.1 打印信息
LOG(LogLevel::INFO) << buffer;
}
}
}
}
为了知道是哪一个客户端发送的消息,可以考虑打印出客户端消息的同时打印出客户端的端口和IP地址,此处需要注意:
首先,因为recvfrom
接口的第四个参数类型是struct sockaddr
,这个结构中默认不带有端口和IP地址,所以还需要转换回网络套接字结构对象
接着,因为网络使用的是大端,而一般的客户机都是小端,所以考虑将收到的端口和IP地址转换回小端字节序,对应地可以使用接口:
// 将大端字节序端口转换为小端字节序
uint16_t ntohs(uint16_t netshort);
// 将IP地址转换为小端字节序并按照点分十进制存储到一个静态空间,注意此处返回的不是字符串
char *inet_ntoa(struct in_addr in);
对于「将IP地址转换为小端字节序并按照点分十进制存储」可以使用接口
inet_ntop
,该接口原型如下:
const char *inet_ntop(int af, const void * src, char *dst, socklen_t size);
该接口的第一个参数表示协议族,网络通信传递AF_INET
,第二个参数传递struct in_addr
类型变量的地址,第三个参数传递一个用于存储结果的空间地址,第四个参数传递第三个参数的大小
因为这个接口可以在函数的内部创建一个临时空间作为接口的第三个实参,如果是多线程情况下就不会出现多个线程访问同一块空间的问题
该接口返回一个IP地址字符串
示例代码如下:
void test()
{
struct sockaddr_in local;
char ipbuffer[64];
const char *ip = ::inet_ntop(AF_INET, &local.sin_addr, ipbuffer, sizeof(ipbuffer));
}
结合这两个接口完善信息的打印:
// ...
struct sockaddr_in temp = reinterpret_cast<struct sockaddr_in>(peer);
// 1.1 打印信息
LOG(LogLevel::INFO) << "Client: "
<< inet_ntoa(temp.sin_addr) << ":"
<< ntohs(temp.sin_port)
<< " send: " << buffer;
// ...
至此,服务器可以显示从客户端接收的消息,第一个任务完成,接下来处理第二个任务,服务器向客户端回复收到的信息。既然是回复,那么肯定涉及到发送信息,此时就可以使用sendto
接口,其原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
如果仔细观察上面的接口可以发现其和之前文件部分的write
接口很类似,前四个参数和recvfrom
一样,不再赘述,下面主要介绍第五个参数:
第五个参数表示目标网络套接字结构,既然是发送,肯定需要知道对方的端口和IP地址,所以第五个参数需要使用者自己创建对象并填充对应值
第六个参数和recvfrom
一样
该接口返回成功发送的字节数,否则返回-1
因为在前面已经获取到了客户端的端口和IP地址,只需要直接赋值就可以正确设置客户端的网络套接字结构,所以基本逻辑如下:
// 2. 回应客户端
struct sockaddr_in peer_temp;
peer_temp.sin_family = AF_INET;
peer_temp.sin_addr.s_addr = temp.sin_addr.s_addr;
peer_temp.sin_port = temp.sin_port;
ssize_t n = sendto(_socketfd, buffer, sizeof(buffer), 0, reinterpret_cast<const struct sockaddr *>(&peer_temp), sizeof(peer_temp));
if (n > 0)
{
// 2.1 打印回复信息
LOG(LogLevel::INFO) << "Server received: "
<< buffer
<< ", and send to: "
<< inet_ntoa(temp.sin_addr) << ":"
<< ntohs(temp.sin_port);
}
停止服务器
停止服务器的逻辑很简单,只需要判断_isRunning
是否为true
,如果为true
就调用文件部分提到的close
接口关闭_socketfd
并将_isRunning
设置为false
即可,为了保证对象销毁时可以自动释放,考虑在析构函数中调用停止服务器的接口:
=== “停止服务器接口”
// 停止服务器
void stop()
{
if (_isRunning)
close(_socketfd);
}
=== “析构函数”
~UdpServer()
{
stop();
}
创建并封装客户端
创建客户端类
对客户端进行封装首先需要大致的框架,因为客户端主要是向服务器发送内容,所以主要任务就是发送信息,也就是启动客户端,对应地客户端也可以有停止客户端的接口,所以基本框架如下:
class UdpClient
{
public:
UdpClient()
{
}
// 启动客户端
void start()
{
}
// 结束客户端
void stop()
{
}
~UdpClient()
{
}
private:
};
创建客户端套接字
创建客户端套接字的方式和服务端,此处不再赘述,代码如下:
enum class ErrorNumber
{
ClientSocketFail = 1, // 创建套接字失败
};
class UdpClient
{
public:
UdpClient()
: _socketfd(-1)
{
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{
LOG(LogLevel::FATAL) << "Client initiate error: " << strerror(errno);
exit(static_cast<int>(ErrorNumber::ClientSocketFail));
}
LOG(LogLevel::INFO) << "Client initiated: " << _socketfd;
}
// ...
private:
int _socketfd; // 套接字文件描述符
};
*绑定客户端IP地址和端口
实际上,客户端并不需要绑定IP地址和端口,如果客户端由程序员绑定,那么假设有两个公司上线的客户端使用的端口是一样的,就会出现一个软件先打开之后可以正常收到服务器发送的数据,但是另外一个软件的服务器就无法正确发送信息到对应的软件上,即一个端口只能对应一个进程,但是一个进程可以有多个端口
那么,客户端难道不需要端口吗?并不是,如果客户端没有端口,那么服务器只能通过IP地址找到具体客户端设备,但是找不到对应地进程,既然如此,客户端的端口怎么确定?实际上这个端口由操作系统自行分配
那么服务器端又为什么需要程序员手动绑定端口号?因为服务器端口号如果是随机的,而软件中请求服务器的端口号是固定的,那么一个软件可能在某一天可以正常收到服务器发送的数据,但是下一次因为服务器端口号是变化的,就无法正常收到信息
综上所述,服务器端需要程序员手动绑定IP地址和端口号,而客户端不需要程序员手动绑定IP地址和端口号,由操作系统自行分配并绑定
启动客户端
启动客户端和启动服务端的设计思路基本一致,基本框架如下:
// 启动客户端
void start()
{
if (!_isRunning)
{
_isRunning = true;
while (true)
{
}
}
}
因为客户端的任务是向服务器端发送数据,所以需要知道服务端的IP地址和端口号,同样可以给定一个默认IP地址和端口,也可以由用户自行设置:
class UdpClient
{
public:
UdpClient(const std::string ip = default_ip, uint16_t port = default_port)
: //...
, _ip(ip)
, _port(port)
{
// ...
}
// ...
private:
// ...
std::string _ip; // 服务器IP地址
uint16_t _port; // 服务器端口号
// ...
};
下面就是设计客户端的任务,本次设计客户端的任务为:向服务器发送信息并回显服务器回复的信息,同样,将这个任务拆为两个任务如下:
- 向服务器发送信息
- 回显服务器回复的信息
首先设计第一个任务,既然是向服务器发送信息,那么就要使用到sendto
接口,这个接口在前面已经介绍过,此处不再赘述,代码如下:
// 1. 向服务器发送数据
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// 1.1 读取输入信息
std::string message;
getline(std::cin, message);
// 1.2 发送数据
ssize_t ret = sendto(_socketfd, message.c_str(), message.size(), 0, reinterpret_cast<const struct sockaddr *>(&local), sizeof(local));
if (ret < 0)
LOG(LogLevel::WARNING) << "客户端未发送成功";
接着设计第二个任务,回显服务器信息本质就是接收服务器的信息并显示,所以需要使用recvfrom
接口,同样,这个接口在前面已经介绍过,此处不再赘述,代码如下:
// 2. 回显服务器的信息
struct sockaddr_in temp;
socklen_t length = sizeof(temp);
char buffer[1024] = {0};
ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, reinterpret_cast<struct sockaddr *>(&temp), &length);
if (n > 0)
LOG(LogLevel::INFO) << "收到服务器信息:" << buffer;
停止客户端
思路与服务器端一致,代码如下:
=== “停止客户端接口”
// 结束客户端
void stop()
{
if (_isRunning)
close(_socketfd);
}
=== “析构函数”
~UdpClient()
{
stop();
}
测试
测试步骤:
- 先启动服务端,再启动客户端
- 客户端向服务器端发送信息
测试目标:
- 客户端可以正常向服务器端发送信息
- 服务端可以正常显示客户端信息并正常向客户端返回客户端发送的信息
- 客户端可以正常显示服务端回复的信息
测试代码如下:
=== “客户端”
#include "udp_client.hpp"
#include <memory>
using namespace UdpClientModule;
using namespace LogSystemModule;
int main(int argc, char *argv[])
{
std::shared_ptr<UdpClient> client;
if (argc == 1)
{
// 创建客户端对象——使用默认端口和IP地址
client = std::make_shared<UdpClient>();
}
else if (argc == 3)
{
// 获取到用户输入的端口和IP地址
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 创建客户端对象——用户自定义端口和IP地址
client = std::make_shared<UdpClient>(ip, port);
}
else
{
LOG(LogLevel::ERROR) << "错误使用,正确使用为:" << argv[0] << " IP地址 端口号(或者二者都不存在)";
exit(3);
}
// 启动客户端
client->start();
return 0;
}
=== “服务端”
#include "udp_server.hpp"
#include <memory>
using namespace UdpServerModule;
int main()
{
// 创建UdpServerModule对象
std::shared_ptr<UdpServer> udp_server = std::make_shared<UdpServer>();
udp_server->start();
return 0;
}
本次设计的客户端支持用户从命令行输入端口和IP地址,否则就直接使用默认,下面是一种结果:
测试云服务器与本地进行通信
因为此时需要确保服务端运行在云服务器的公网IP上,否则客户端无法找到服务端,服务端测试代码修改如下:
#include "udp_server.hpp"
#include <memory>
using namespace UdpServerModule;
int main(int argc, char *argv[])
{
// 创建UdpServerModule对象
std::shared_ptr<UdpServer> udp_server;
if (argc == 1)
{
udp_server = std::make_shared<UdpServer>();
}
else if (argc == 3)
{
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
udp_server = std::make_shared<UdpServer>(ip, port);
}
udp_server->start();
return 0;
}
测试云服务器与本地进行通信最直接的步骤如下:
- 将服务端程序拷贝到云服务器
- 本地作为客户端,通过云服务器的公网IP地址连接云服务器的服务端
- 客户端向云服务器发送信息
根据上面的步骤依次进行:
将服务端程序拷贝到云服务器:
因为传输到云服务器的文件默认是没有可执行权限的,所以需要使用chmod
指令设置可执行权限:
接着,指定IP地址为云服务器公网IP地址,端口为8080,运行云服务器的服务端:
从上图可以看到,虽然创建套接字成功,但是绑定失败。之所以出现这个问题是因为云服务器的公网IP地址是不允许用户自行绑定的,但是如果是虚拟机就可以进行绑定IP地址(非127.0.0.1地址)
那么有没有什么办法解决呢?有,但是在实现解决方案之前先了解下面的知识:
前面的代码在启动时都为服务器设置启动IP地址,但是思考一个问题,启动服务器真的需要指定IP地址吗?并不需要,那如果不需要指定服务器端IP地址,客户端怎么找到服务器呢?回到这个问题之前先解释为什么启动服务器不需要指定IP地址。实际上,之所以不需要IP地址是因为一台服务器可能有多个IP地址,此时如果服务器固定IP地址,那么此时就会出现服务器只能接收传送到固定IP地址的信息,就算服务器有很多IP地址也只有一个IP地址可以使用,很明显这个效果并不符合UDP协议的特点,因为UDP协议是面向无连接的,既然都不需要连接,为什么还需要指定IP地址,所以启动服务器不需要指定IP地址。有了这个概念之后,再解释没有指定服务器端IP地址,客户端怎么找到服务器,实际上只需要客户端启动的时候指定已经知道的服务器IP地址和端口号即可,对应的服务器只需要设置好端口号即可完成通信
有了上面的概念,对服务器端代码修改如下:
=== “服务器端封装代码”
// 默认端口和IP地址
// const std::string default_ip = "127.0.0.1"; 去除
// ...
class UdpServer
{
public:
UdpServer(uint16_t port = default_port /* const std::string &ip = default_ip 服务器端不需要指定IP地址 */)
: _socketfd(-1), _port(port), _isRunning(false) /* , _ip(ip) */
{
// ...
// 绑定端口号和IP地址
// ...
// saddrIn.sin_addr.s_addr = inet_addr(_ip.c_str());
// 服务器端IP地址设置为任意
saddrIn.sin_addr.s_addr = INADDR_ANY;
// ...
}
// ...
private:
// ...
// std::string _ip; // 点分十进制IP地址——去除
// ...
};
=== “主函数”
#include "udp_server.hpp"
#include <memory>
using namespace UdpServerModule;
int main(int argc, char *argv[])
{
// 创建UdpServerModule对象
std::shared_ptr<UdpServer> udp_server;
if (argc == 1)
{
udp_server = std::make_shared<UdpServer>();
}
else if (argc == 2)
{
// std::string ip = argv[1]; 去除
uint16_t port = std::stoi(argv[1]);
udp_server = std::make_shared<UdpServer>(port);
}
else
{
LOG(LogLevel::ERROR) << "错误使用,正确使用:" << argv[0] << " 端口(或者不写)";
exit(4);
}
udp_server->start();
return 0;
}
在上面的服务器端封装代码中,因为不需要指定IP地址,在绑定时,对于struct sockaddr_in
中的IP地址字段设置为INADDR_ANY
表示0.0.0.0
,即任意IP地址
在底层实际上就是0:
#define INADDR_ANY ((in_addr_t) 0x00000000)
现在再按照前面提到的三步进行云服务器与本地进行通信:
将服务端程序拷贝到云服务器:
前面两步不变,只演示最后一步:
可以看到服务器已经正常启动了
本地作为客户端,通过云服务器的公网IP地址连接云服务器的服务端:
启动本地的客户端:
客户端向云服务器发送信息
如果客户端可以正常发送信息并且回显服务器回复的信息,服务器可以正常显示来自客户端的信息并且正常回复客户端收到的信息,那么说明连接成功:
从上图中可以看到,客户端和服务端已经可以正常通信,至此,从前面的本地通信测试完成了网络通信
需要注意,如果使用修改后的代码可以实现本地通信,但是使用修改后的代码无法实现网络通信(例如客户端正确指定云服务器IP地址和端口号并正常启动,服务器也是正常启动,但是客户端发送消息服务端并没有反应),此时可能是云服务器的安全组问题,如果云服务器的安全组并没有允许其他设备通过UDP协议向当前云服务器发送信息,那么就会出现客户端发送消息服务端没有反应的情况。对于这种情况可以在云服务的安全组配置中允许UDP协议和对应的端口
部分细节优化
在前面对服务端和客户端进行封装时,可以发现有些代码其实是可以进行合并的,还有一部分代码是可以进行封装的,下面就这些代码进行优化
合并重复代码
在出现错误时,服务端和客户端的退出码应该是一致的,此时可以对错误码枚举类进行抽取放到一个公共的文件中:
// 文件errors.hpp中
#pragma once
// 错误码枚举类
enum class ErrorNumber
{
SocketFail = 1, // 创建套接字失败(供服务端和客户端使用)
BindSocketFail, // 绑定失败(供服务端使用)
};
再在服务端封装文件和客户端封装文件中引入该文件并修改指定位置的代码即可,不再演示