【Linux网络】Socket编程:UDP网络编程实现ChatServer
上篇文章我们实现了英译汉的网络字典,客服端向服务端发送英文,服务端接收数据后回调处理,将翻译后的中文再转发给客户端,这其实和EchoSever一样都是一对一的网络通信。我们也可以实现多个客户端之间进行网络通信,通过服务端将一个客户端发送的消息转发给所有客户端,这样大家都能够看到你发的消息,以此来达到一个简易聊天室的效果。所以我们服务端在处理数据时就不再是简单的进行翻译了,而是要实现一个可以路由转发的功能。
文章目录
- 1. 封装路由转发功能的类
- 2. UdpServer.hpp——服务端通信
- 3. UdpServer.cc——服务端主程序
- 4. UdpServer.cc——客户端主程序
- 5. 引入线程池
- 6. 补充拓展
- 6.1 关于inet_ntoa
- 6.2 inet_ntop - 二进制到字符串转换
- 6.3 inet_pton - 字符串到二进制转换
1. 封装路由转发功能的类
现在我们就不再是将数据进行翻译了,而是进行路由转发
框架如下:
#pragma once#include <iostream>
#include <string>
#include <vector>
#include "Log.hpp"
#include "InetAddr.hpp"using namespace LogModule;class Route
{
public:Route(){}// 路由转发信息void MessageRoute(int sockfd, const std::string& message, InetAddr& peer){}~Route() {}
private:std::vector<InetAddr> _online_user; // 管理在线用户
};
我们要转发给所有在线用户,要怎么发呢?先描述再组织,通过数组来管理我们的在线用户(当然也可以使用其他数据结构,这里采用数组),而在线用户我们可以使用ip和端口号来标识,同时这里我们默认为第一次发送消息就等同于用户登录。
注意:参数中要有文件描述符,因为我们转发给每个在线用户时,是使用sendto系统调用,该函数参数中是需要文件描述符的
那么我们在转发前需要判断用户存不存在,不存在就要把用户添加到数组中
bool IsExist(InetAddr& peer){for(auto& user : _online_user){if(user == peer){return true;}}return false;}
我们直接将 InetAddr
类的对象比较是不可以的,因为我们没有在 InetAddr
类中重载比较运算符,所以我们可以先重载一个比较运算符
bool operator==(const InetAddr& addr){return _ip == addr._ip && _port == addr._port;}
添加新用户
void AddUser(InetAddr& peer){LOG(LogLevel::INFO) << "新增一个在线用户: " << peer.StringAddr();_online_user.push_back(peer);}
同样 ,这里为方便展示每个用户的信息,我们也还可以在 InetAddr
类中实现一个函数,直接返回用户信息的字符串
std::string StringAddr(){return _ip + ":" + std::to_string(_port);}
如果用户想要退出呢?我们可以让用户通过输入 “QUIT” 来确认用户是否需要退出
// 路由转发信息void MessageRoute(int sockfd, const std::string& message, InetAddr& peer){if(!IsExist(peer)){AddUser(peer);}std::string send_message = peer.StringAddr() + "# " + message;// 转发for(auto& user : _online_user){sendto(sockfd, send_message.c_str(), send_message.size(), 0, (const struct sockaddr*)&user.NetAddr(), sizeof(user.NetAddr()));}if(message == "QUIT"){LOG(LogLevel::INFO) << "在线用户: " << peer.StringAddr() << " 退出聊天";DeleteUser(peer);}}
注意:我们转发消息的时候,发给每个在线用户是需要每个用户的网络地址的,所以我们可以在实现一个获取用户网络地址的函数(InetAddr中实现)
struct sockaddr_in& NetAddr(){return _addr;}
转发消息也需要转发给自己,因为我自己也需要看到我发出的消息
最后我们这里还需要实现一个删除用户函数
void DeleteUser(InetAddr& peer){for(auto iter = _online_user.begin(); iter != _online_user.end(); iter++){if(*iter == peer){LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr() << " 成功";_online_user.erase(iter);break;}}}
2. UdpServer.hpp——服务端通信
这里我们服务端需要修改一下,我们现在接收客户端发来的消息后,并不是要将该消息处理好之后再次转发回原来的客户端,而是需要回调进行路由转发,将消息转发给所有在线用户,所以我们回调不需要得到返回结果,并且参数还要多增加一个文件描述符,因为路由转发需要用到文件描述符
那么包装器function需要修改
using func_t = std::function<void(int, const std::string&, InetAddr&)>;
同时,我们也不需要再给原来的客户端再次转发了,因为我们路由转发时,已经给包括原来客户端在内的所有在线用户都转发过了
完整代码:
#pragma once#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "InetAddr.hpp"using namespace LogModule;using func_t = std::function<void(int, const std::string&, InetAddr&)>;class UdpServer
{
public:UdpServer(uint16_t port, func_t func):_socketfd(-1), _port(port), _isrunning(false), _func(func){}void Init(){_socketfd = socket(AF_INET, SOCK_DGRAM, 0);if(_socketfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(1);}LOG(LogLevel::INFO) << "socket success, socketfd: " << _socketfd;// 填充sockaddr_in结构体struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;// 绑定IPv4地址结构int n = bind(_socketfd, (struct sockaddr*)&local, sizeof(local));if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(2);}LOG(LogLevel::INFO) << "bind success, sockfd : " << _socketfd;}void Start(){_isrunning = true;while(_isrunning){char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if(n > 0){// 服务端需要知道客户端的ip和端口号InetAddr client(peer);buffer[n] = 0;// 回调进行路由转发、_func(_socketfd, buffer, client);}}}~UdpServer() {}
private:int _socketfd;uint16_t _port; // 端口号bool _isrunning;func_t _func;
};
3. UdpServer.cc——服务端主程序
这里我们要实例化出一个路由转发的类,由它提供路由转发的服务
#include <memory>
#include "UdpServer.hpp"
#include "Route.hpp"// ./udpserver port
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " port" << std::endl;return 1;}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();// 路由服务Route route;// 网络服务器对象提供网络通信功能std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&route](int sockfs, const std::string& message, InetAddr& client){route.MessageRoute(sockfs, message, client);});usvr->Init();usvr->Start();return 0;
}
4. UdpServer.cc——客户端主程序
我们先来看一下客户端主程序的代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"using namespace LogModule;// ./udpclient server_ip server_port
int main(int argc, char* argv[])
{// 客户端需要绑定服务器的ip和portif(argc != 3){std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}Enable_Console_Log_Strategy();std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){LOG(LogLevel::FATAL) << "socket error";return 2;}// 不需要手动绑定ip和端口,操作系统会分配一个临时端口与ip进行绑定// 填写服务器信息struct sockaddr_in server;memset(&server, 0, sizeof(server)); // 这里使用memsetserver.sin_family = AF_INET;server.sin_port = htons(server_port); // 转成网络字节序server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 字符串->网络字节序while(true){// 从键盘获取要发送的数据std::string input;std::cout << "Client Enter# ";std::getline(std::cin, input);// 发送数据给服务器ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));if(n < 0){LOG(LogLevel::FATAL) << "sendto error";return 3;}// 接收服务器转发回来的数据并回显在控制台上char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if(m > 0){buffer[m] = 0;std::cout << buffer << std::endl;}}return 0;
}
这里我们如果不发送消息就会阻塞在这,但是我们现实中,一般在群里聊天时,尽管我自己不发送消息,但是我也能看到别人发的消息啊,但是目前我们客户端的代码如果不发送消息就会一直阻塞,收不到任何其他客户端发送的消息,那要怎么做呢?
仔细想想,前面我们在学习多线程的时候,我们其实可以让一个线程发,一个线程收,并行处理,这样哪怕我今天不说话,被阻塞在这,但是不影响另一个线程收消息。
那么这里我们可以引入之前封装的线程
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "Thread.hpp"using namespace LogModule;
using namespace ThreadModlue;int sockfd = 0;
std::string server_ip;
uint16_t server_port = 0;
pthread_t id;void Recv()
{while(true){// 接收服务器转发回来的数据并回显在控制台上char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (m > 0){buffer[m] = 0;std::cout << buffer << std::endl;}}
}void Send()
{// 填写服务器信息struct sockaddr_in server;memset(&server, 0, sizeof(server)); // 这里使用memsetserver.sin_family = AF_INET;server.sin_port = htons(server_port); // 转成网络字节序server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 字符串->网络字节序while (true){// 从键盘获取要发送的数据std::string input;std::cout << "Client Enter# ";std::getline(std::cin, input);// 发送数据给服务器ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));if (n < 0){LOG(LogLevel::FATAL) << "sendto error";exit(3);}if(input == "QUIT"){pthread_cancel(id);break;}}
}// ./udpclient server_ip server_port
int main(int argc, char *argv[])
{// 客户端需要绑定服务器的ip和portif (argc != 3){std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}Enable_Console_Log_Strategy();server_ip = argv[1];server_port = std::stoi(argv[2]);sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0){LOG(LogLevel::FATAL) << "socket error";return 2;}// 不需要手动绑定ip和端口,操作系统会分配一个临时端口与ip进行绑定Thread recver(Recv);Thread sender(Send);recver.Start();sender.Start();id = recver.Id();recver.Join();sender.Join();return 0;
}
由于我们创建两个线程之后的执行函数,我们定义在了全局,所以我们需要用到的一些变量也需要改成全局才能在线程中使用
运行测试一下:
我们可以看到输出混在了一起,由于我们只是实现一个简单的聊天室,不去做前端的一些相关处理,但是这里我们可以使用重定向,可以将客户自己的输入,输出到标准输出中,所有客户发送的消息则输出到标准错误中,那么我们就可以再打开一个服务器,将标准错误重定向到该服务器的终端,这样就能解决输入输出都混在一起的情况。或者也可以使用命名管道打印在另一个服务器的终端,这里我们就不做演示了
5. 引入线程池
我们客户端可以使用多线程,服务端其实也可以,服务端只需要接收消息,然后将消息作为一个任务,后面会有多个客户端给服务端发送消息,那么就会有多个任务,把任务交给准备好的线程池去执行这个任务,也就是进行路由转发
那不就相当于我们服务端是一个生产者,线程池就是一个消费者,那这不就是我们前面说过的生产者消费者模型嘛
那么在服务端主程序中,我们就不应该直接让Route类型的对象去完成路由转发,而是交给线程池,让线程池中的线程去通过Route来完成路由转发这个任务。
#include <memory>
#include "UdpServer.hpp"
#include "Route.hpp"
#include "ThreadPool.hpp"using namespace ThreadPoolModule;using task_t = std::function<void()>;// ./udpserver port
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " port" << std::endl;return 1;}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();// 路由服务Route route;// 线程池auto tp = ThreadPool<task_t>::GetInstance();// 网络服务器对象提供网络通信功能std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&route, &tp](int sockfd, const std::string& message, InetAddr& client){task_t t = std::bind(&Route::MessageRoute, &route, sockfd, message, client);tp->Enqueue(t);});usvr->Init();usvr->Start();return 0;
}
这里我们使用了bind包装器,在【C++】专栏中我们有专门介绍过包装器
运行测试:
其实在引入线程池之后,我们这里就会出现一些新的问题,还记得在我们线程池中,执行任务不是互斥的,虽然取任务是在互斥量中进行的,但是执行任务在临界区外,所以我们管理在线用户的vector就是一个全局资源,没有互斥锁的保护,我们线程池在访问全局资源时就会产生并发问题
那我们就不仅可以使用一个数组管理在线用户,还可以使用一个数组(其他数据结构也可以)来管理删除的用户,将任务拆分出来,今天我们就是一股脑的就是一个任务,其实可以拆分成新增用户的任务,删除用户的任务,路由转发的任务等等,我们就可以在执行不同任务时加锁,但是这些需要配合协议来完成,但是我们今天还只是对协议有一个朴素的理解,所以我们今天做不了,但是我们可以做。
但是我们也可以直接简单粗暴的在使用消息路由转发函数时加锁
// 路由转发信息void MessageRoute(int sockfd, const std::string& message, InetAddr& peer){LockGuard lockguard();if(!IsExist(peer)){AddUser(peer);}std::string send_message = peer.StringAddr() + "# " + message;// 转发for(auto& user : _online_user){sendto(sockfd, send_message.c_str(), send_message.size(), 0, (struct sockaddr*)&user.NetAddr(), sizeof(user.NetAddr()));}if(message == "QUIT"){LOG(LogLevel::INFO) << "在线用户: " << peer.StringAddr() << " 退出聊天";DeleteUser(peer);}}
6. 补充拓展
6.1 关于inet_ntoa
#include <arpa/inet.h>char *inet_ntoa(struct in_addr in);
inet_ntoa
这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?
man手册上说,inet_ntoa
函数,是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放.
那么问题来了,如果我们调用多次这个函数,会有什么样的效果呢?
运行结果如下:
因为 inet_ntoa
把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果.
同时,inet_ntoa 是非线程安全的,在多线程环境中可能产生竞争条件。因此,在多线程环境下,推荐使用 inet_ntop
。这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题;
6.2 inet_ntop - 二进制到字符串转换
功能
将网络字节序的二进制 IP 地址转换为点分十进制字符串格式。
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数说明
-
af
:地址族,如 AF_INET (IPv4) 或 AF_INET6 (IPv6) -
src
:指向网络地址结构的指针 -
dst
:目标缓冲区,用于存储转换后的字符串 -
size
:目标缓冲区的大小
返回值
-
成功:指向 dst 的指针
-
失败:NULL,设置 errno
所以我们下面可以将 InetAddr 类中的网络字节序转换为点分十进制字符串格式的函数修改一下
InetAddr(struct sockaddr_in &addr): _addr(addr){_port = ntohs(_addr.sin_port); // 从网络中拿到的数据//_ip = inet_ntoa(_addr.sin_addr); // 网络字节序转点分十进制char ipbuffer[64];inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(ipbuffer));_ip = ipbuffer;}
6.3 inet_pton - 字符串到二进制转换
介绍完 inet_ntop
,那就不得不再介绍一下 inet_pton
功能
将点分十进制字符串格式的 IP 地址转换为网络字节序的二进制格式。
#include <arpa/inet.h>// 字符串 -> 二进制 (Presentation to Network)
int inet_pton(int af, const char *src, void *dst);
参数
-
af
:地址族 - AF_INET (IPv4) 或 AF_INET6 (IPv6) -
src
:源字符串(点分十进制 IP 地址) -
dst
:目标缓冲区,存储二进制结果
返回值
-
1:成功
-
0:输入不是有效的 IP 地址格式
-
-1:错误(地址族不支持等),设置 errno
我们这里还可以增加一个主机转网络构造函数
InetAddr(const std::string &ip, uint16_t port):_ip(ip), _port(port){// 主机转网络memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);_addr.sin_port = htons(_port);}