前言
我们接着加油!!!
一、关于客户端的绑定问题
首先,由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要进行端口号的绑定,而客户端不需要。
因为服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。
而客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。
如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。
也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。
二、启动客户端
增加服务端IP地址和端口号
作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,因此在客户端类当中需要引入服务端的IP地址和端口号,此时我们就可以根据传入的服务端的IP地址和端口号对对应的成员进行初始化。
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
:_sockfd(-1)
,_server_port(server_port)
,_server_ip(server_ip)
{}
~UdpClient()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
当客户端初始化完毕后我们就可以将客户端运行起来,由于客户端和服务端在功能上是相互补充的,既然服务器是在读取客户端发来的数据,那么客户端就应该想服务端发送数据。
sendto函数
UDP客户端发送数据的函数叫做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,同时错误码会被设置。
注意:
- 由于UDP不是面向连接的,因此除了传入待发送的数据以外还需要指明对端网络相关的信息,包括IP地址和端口号等。
- 由于sendto函数提供的参数也是 struct sockaddr* 类型的,因此我们在传入结构体地址时需要将 struct sockaddr_in* 类型进行强转。
启动客户端函数
现在客户端要发送数据给服务端,我们可以让客户端获取用户输入,不断将用户输入的数据发送给服务端。
需要注意的是,客户端中存储的服务端的端口号此时是主机序列,我们需要调用 htons函数 将其转为网络序列后再设置进 struct sockaddr_in 结构体。同时,客户端中存储的服务端的IP地址是字符串IP,我们需要通过调用inet_addr 函数将其转为整数IP后再设置进 struct sockaddr_in 结构体。
class UdpClient
{
public:
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));
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
引入命令行参数
鉴于构造客户端时需要传入对应服务端的IP地址和端口号,我们这里也可以引入命令行参数。当我们运行客户端时直接在后面跟上对应服务端的IP地址和端口号即可。
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
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;
}
需要注意的是,argv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数。然后我们就可以用这个IP地址和端口号来构造客户端了,客户端构造完成并初始化后就可以调用Start函数启动客户端了。
三、本地测试
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8081,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8081。
客户端运行之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,这时我们在服务端的窗口也看到我们输入的内容。
此时我们再用 netstat命令 查看网络信息,可以看到服务端的端口是8081,客户端的端口是59960。这里客户端能被 netstat命令 查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。
INADDR_ANY
现在我们已经通过了本地测试,接下来就需要进行网络测试了,那是不是直接让服务端绑定我的公网IP,此时这个服务端就能够被外网访问了呢?
理论上确实是这样的,就比如我的服务器的公网IP是43.138.129.27,这里用ping命令也是能够ping通的。
现在我将服务端设置的本地环回改为我的公网IP,此时当我们重新编译程序再次运行服务端的时候会发现服务端绑定失败。
由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,如果需要让外网访问,此时我们需要bind 0。系统当当中提供的一个INADDR_ANY,这是一个宏值,它对应的值就是0。
因此如果我们需要让外网访问,那么在云服务器上进行绑定时就应该绑定INADDR_ANY,此时我们的服务器才能够被外网访问。
绑定 INADDR_ANY 的好处
当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个IP地址,但一台服务器上端口号为8081的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8081的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8081的服务的数据,系统都会可以将数据自底向上交给该服务端。
因此服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有的服务器具体在操作的时候用的也就是这种方案。
当然,如果你既想让外网访问你的服务器,但你又指向绑定某一个IP地址,那么就不能用云服务器,此时可以选择使用虚拟机或者你自定义安装的Linux操作系统,那个IP地址就是支持你绑定的,而云服务器是不支持的。
更改代码
因此,如果想要让外网访问我们的服务,我们这里就需要将服务器类当中IP地址相关的代码去掉,而在填充网络相关信息设置struct sockaddr_in结构体时,将设置的IP地址改为INADDR_ANY就行了。由于INADDR_ANY的值本质就是0,不存在大小端的问题,因此在设置时可以不进行网络字节序的转换。
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;
//填充网络通信相关信息
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; //绑定INADDR_ANY
//绑定
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;
}
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
此时当我们再重新编译运行服务器时就不会绑定失败了,并且此时当我们再用netstat命令查看时会发现,该服务器的本地IP地址变成了0.0.0.0,这就意味着该UDP服务器可以在本地读取任何一张网卡里面的数据。
四、简易回声服务器
由于在进行网络测试的时候,当客户端发送数据给服务端时,服务端会将从客户端收到的数据进行打印,因此服务端是能够看到现象的。但客户端一直在向服务端发送数据,在客户端这边看不出服务端是否收到了自己发送的数据。
服务端代码编写
鉴于此,我们可以将该服务器改成一个简单的回声服务器。当服务端收到客户端发来的数据后,除了在服务端进行打印以外,服务端可以调用 sento函数 将收到的数据重新发送给对应的客户端。
需要注意的是,服务端在调用sendto函数时需要传入客户端的网络属性信息,但服务端现在是知道客户端的网络属性信息的,因为服务端在此之前就已经通过 recvfrom函数 获取到了客户端的网络属性信息。
void Start()
{
#define SIZE 128
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);
}
}
客户端代码编写
服务端的代码改了之后,对应客户端的代码也得改改。当客户端发完数据给服务端后,由于服务端还会将该数据重新发给客户端,因此客户端发完数据后还需要调recvfrom来读取服务端发来的响应数据。
在客户端调用recvfrom函数接收服务端发来的响应数据时,客户端同时也需要读取服务端与网络相关的各种信息。虽然客户端早已知道服务端的网络信息了,此时服务端的网络信息已经不重要了,但还是建议不要把参数设置为空,这样可能会出问题,所以我们还是用一个临时变量将服务端的网络信息读取一下。
而客户端接收到服务端的响应数据后,将数据原封不动的打印出来就行了。此时客户端发送给服务端的数据,除了在服务端会打印显示以外,服务端还会将数据再重新发回给客户端,此时客户端也会接收到响应数据然后将该数据进行打印。
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));
#define SIZE 128
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;
}
}
}
此时当我们测试回声服务器时,在服务端和客户端就都能够看到对应的现象,这样就能够判断通信是否正常了。
五、网络测试
我们可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络级别的测试。为了保证程序在你们的机器是严格一致的,可以选择在编译客户端时 携带 -static 选项进行静态编译。
此时我们可以先使用 sz命令 将该客户端可执行程序下载到本地机器,然后将该程序发送给你的朋友,这就跟我们自己在PC端上下载文件是一样的道理~
接着你先把你的服务器启动起来,然后你的朋友将你的IP地址和端口号作为命令行参数运行客户端,就可以访问你的服务器了。
总结
结束了,但是可能需要再缓解一下!!!