客户端
客户端创建套接字
同样的,我们把客户端也封装成一个类,当我们定义出一个客户端对象后也是需要对其进行初始化,而客户端在初始化时也需要创建套接字,之后客户端发送数据或接收数据也就是对这个套接字进行操作。
客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_DGRAM,当客户端被析构时也可以选择关闭对应的套接字。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。
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和端口,所以客户端上线的就向这个IP和端口发出请求,这时因为客户开始发数据了(发上线请求),系统就给客户端分配一个随机端口,这个端口和客户端的IP会随着上线请求一起发给服务器,服务收到上线请求后就可以从中获起发此请求的客户的IP和端口,接下来服务器就可以利用获起的IP和端口给客户端回应消息了。
总之一句话,客户端是主动连接,而服务器是等待连接。
也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。
启动客户端
增加服务器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:期望写入数据的字节数。lags:写入的方式。一般设置为0,表示阻塞写入。
- fdest_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,客户端的端口是53979。这里客户端能被netstat
命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。
INADDR_ANY
现在我们已经通过了本地测试,接下来就需要进行网络测试了,那是不是直接让服务端绑定我的公网IP,此时这个服务端就能够被外网访问了呢?
理论上确实是这样的,就比如我的服务器的公网IP是175.178.89.191
,这里用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端软件的过程,我们下软件下的实际上就是客户端的可执行程序,而与之对应的服务端就在Linux服务器上部署着。
当你的朋友收到这个客户端的可执行程序后,可以通过rz
命令或拖拽的方式将这个可执行程序上传到他的云服务器上,然后通过chmod
命令给该文件加上可执行权限。
进行网络测试
此时你先把你的服务器启动起来,然后你的朋友将你的IP地址和端口号作为命令行参数运行客户端,就可以访问你的服务器了。