Linux网络:UDP socket - 简单谈天室

打印 上一主题 下一主题

主题 1898|帖子 1898|积分 5694


谈天通讯架构

本博客基于Linux实现一个简单的谈天通讯服务,以认识Linux的网络接口。
总代码地址:[UDPsocket-简单谈天通讯]
文件结构如下:

在server文件夹中,包含三个类,分别写在三个文件中:


  • InetAddr.hpp:记载通讯主机的ip和port,方便进行通讯
  • UdpServer.hpp:完成服务端UDP套接字的创建,并接收来自客户端的消息
  • MessageRouter:对收到的消息进行业务处理
两个文件中的main.cpp是源文件,分别编译得到服务端与客户端的可实行文件。

Server

InetAddr

  1. class InetAddr
  2. {
  3. private:
  4.     struct sockaddr_in _addr;
  5.     std::string _ip;
  6.     uint16_t _port;
  7. };
复制代码
类成员:

  • _addr:套接字地址
  • _ip:主机地址
  • _port:主机端口号
其实在_addr内部已经存储了地址与端口号,这一层封装的意义是提供更加便捷的接口来访问地址与端口。


  • 构造函数
  1. InetAddr(const struct sockaddr_in& addr)
  2.     : _addr(addr)
  3. {
  4.     _ip = inet_ntoa(addr.sin_addr);
  5.     _port = ntohs(addr.sin_port);
  6. }
复制代码
构造函数接受一个套接字地址addr,随后初始化_ip与_port。

  • 对_ip地址来说,要把四字节的序列通过inet_ntoa转化为字符串形式
  • 对_port端口来说,则是要把网络字节序转化为主机字节序


  • 基本get接口:
  1. std::string ip()
  2. {
  3.     return _ip;
  4. }
  5. uint16_t port()
  6. {
  7.     return _port;
  8. }
  9. struct sockaddr_in addr()
  10. {
  11.     return _addr;
  12. }
复制代码
这些接口用于外部访问类成员。


  • 操纵符重载operator==
  1. bool operator==(const InetAddr& other) const
  2. {
  3.     return _ip == other._ip && _port == other._port;
  4. }
复制代码
后续要完成客户的网络地址之间的身份标识,通过_ip + _port的组合,来确定一个客户,这样就可以区分前后是否是同一个人发消息。因此此处要重载operator==,辨别两个UDP报文是否是同一个客户发送的。

UdpServer



  • 类架构:
  1. using func_t = std::function<void(int sockfd, std::string message, InetAddr cliAddr)>;
  2. class UdpServer
  3. {
  4. private:
  5.     int _sockfd;
  6.     uint16_t _port;
  7.     func_t _callback;
  8. };
复制代码
类成员:

  • _sockfd:创建网络套接字得到的文件形貌符,后续通过读写该形貌符操纵网络
  • _port:指定服务端监听的端口
  • _callback:一个回调函数,当服务端收到消息后,调用该回调函数处理信息,这一层操纵的意义在于把UDP套接字与业务逻辑进行解耦


  • 枚举错误码:
  1. enum
  2. {
  3.     SOCKET_ERROR = 1, // 套接字错误
  4.     BIND_ERROR,       // 绑定错误
  5. };
复制代码
为例方便后续指明错误范例,此处枚举了三个错误码。


  • 构造:
  1. UdpServer(uint16_t port, func_t callback)
  2.     : _port(port)
  3.     , _callback(callback)
  4. {
  5.     _sockfd = socket(AF_INET, SOCK_DGRAM, 0); // 创建套接字
  6.     if (_sockfd < 0)
  7.         exit(SOCKET_ERROR);
  8.     struct sockaddr_in addr;  // 初始化套接字信息
  9.     bzero(&addr, sizeof(addr));
  10.     addr.sin_family = AF_INET;
  11.     addr.sin_port = htons(_port);
  12.     addr.sin_addr.s_addr = INADDR_ANY;
  13.     // 绑定套接字
  14.     int n = bind(_sockfd, (struct sockaddr*)&addr, sizeof(addr));
  15.     if (n < 0)
  16.         exit(BIND_ERROR);
  17. }
复制代码
在构造函数中,实现UDP的套接字创建。
参数:

  • port:该服务开放的端口
  • callback:上层处理消息的业务逻辑的回调函数
随后通过socket函数创建套接字,参数:

  • AF_INET:使用ipv4通讯
  • SOCK_DGRAM:使用UDP进行通讯
  • 0:不消管,直接填0即可
得到一个文件形貌符_sockfd,后续通过操纵该文件形貌符进行网络通讯。
但是当前套接字还只是一个内存中的变量,操纵体系还没有进行真正的网络监听,此时要将套接字绑定起来。
起首初始化套接字地址的信息:
  1. bzero(&addr, sizeof(addr)); // 清空内存原有内容
  2. addr.sin_family = AF_INET; // 使用ipv4通信
  3. addr.sin_port = htons(_port); // 使用指定端口号
  4. addr.sin_addr.s_addr = INADDR_ANY; // 绑定地址
复制代码
此处addr.sin_addr.s_addr表示该套接字,接收来自于哪些地址的哀求,比如填入127.0.0.1,那么就只有127.0.0.1地址可以与该服务通讯。而填入0.0.0.0表示可以接收任意地址的哀求,此处INADDR_ANY就代表0.0.0.0,只不过被封装为了一个宏。
最后通过bind进行绑定,此时就创建了一个UDP套接字,基于ipv4进行通讯,监听任意地址发送的哀求。


  • 开始服务:
  1. void start()
  2. {
  3.     while (true)
  4.     {
  5.         char buffer[1024];
  6.         struct sockaddr_in cliAddr;
  7.         bzero(&cliAddr, sizeof(cliAddr));
  8.         socklen_t len = sizeof(cliAddr);
  9.         int n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&cliAddr, &len);
  10.         if (n > 0)
  11.         {
  12.             buffer[n] = '\0';
  13.             _callback(_sockfd, buffer, cliAddr);
  14.         }
  15.     }
  16. }
复制代码
最后写一个start函数,这个函数用于接收来自客户端的消息。通过recvfrom接口读取网络中的UDP报文,读取到buffer数组中,为了防止字符串没有末了,最后buffer[n] = '\0'添加一个字符串的终止符。因为recvfrom返回接收到的字符个数,所以最后一个字符的下标为n - 1,在下标n处补充一个'\0'。
接收到消息后,通过_callback把套接字文件形貌符_sockfd,接收到的数据buffer以及客户端信息cliAddr发送给业务层处理。
此处注意:
  1. using func_t = std::function<void(int sockfd, std::string message, InetAddr cliAddr)>;
复制代码
这是_callback函数的范例,其中std::string message是一个普通的std::string范例,他不是引用,也不是指向字符串的指针。因为buffer是个在栈区的数组,比及下一轮while循环,这个数组的内容就是未界说的。所以会导致指针越界,访问到错误数据等问题。因此不能使用指针或引用,而是让std::string对buffer内的数据进行一次拷贝。cliAddr同理,不是一个引用大概指针,要进行一次拷贝。

MessageRouter

MessageRouter是业务层的逻辑,接收到一条消息后,处理消息并发送回给客户端。

如图,当UdpServer接收到来自客户端的消息后,MessageRouter要把这个消息转发给其他客户端,也就是说MessageRouter的任务就是转发消息。而发送消息必要通过套接字文件形貌符,这也就是为什么在刚才的_callback要传一个_sockfd。


  • 类架构:
  1. class MessageRouter
  2. {
  3. private:
  4.     std::vector<InetAddr> _online_user;
  5.     pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
  6. };
复制代码
MessageRouter要维护全部的用户,所以使用一个数组来存储全部的客户端。每个客户端用一个InetAddr表示,也就是一个ip + port确定一个唯一的客户端。
因为后续要引入多线程,会出现并发访问数组的问题,此处使用一把_mutex锁来进行并发控制。


  • 增加用户
  1. bool addUser(const InetAddr& user)
  2. {
  3.     pthread_mutex_lock(&_mutex);
  4.     for (auto& o_user : _online_user) // 遍历数组
  5.     {
  6.         if (user == o_user) // 用户已存在
  7.         {
  8.             pthread_mutex_unlock(&_mutex);
  9.             return false;
  10.         }
  11.     }
  12.     _online_user.push_back(user); // 新增用户
  13.     pthread_mutex_unlock(&_mutex);
  14.     return true;
  15. }
复制代码
增加一个用户,就要访问数组_online_user,访问之前要加锁,访问竣事后再解锁。
访问前先遍历数组,检察是否当前用户已经存在,如果存在直接返回,返回前别忘了解锁。如果不存在,则尾插新用户到数组中。


  • 删除用户
  1. bool delUser(const InetAddr& user)
  2. {
  3.     pthread_mutex_lock(&_mutex);
  4.     auto it = find(_online_user.begin(), _online_user.end(), user);
  5.     if (it == _online_user.end())
  6.     {
  7.         pthread_mutex_unlock(&_mutex);
  8.         return false;
  9.     }
  10.     _online_user.erase(it);
  11.     pthread_mutex_unlock(&_mutex);
  12.     return true;
  13. }
复制代码
删除用户与添加用户同理,先遍历数组,如果用户不存在,直接返回。如果存在,那么删掉该用户。


  • 消息转发:
  1. struct SendPackage
  2. {
  3.     SendPackage(MessageRouter* self, int sockfd, std::string message, InetAddr cliAddr)
  4.         : _self(self)
  5.         , _sockfd(sockfd)
  6.         , _message(message)
  7.         , _cliAddr(cliAddr)
  8.     {}
  9.     MessageRouter* _self;
  10.     int _sockfd;
  11.     std::string _message;
  12.     InetAddr _cliAddr;
  13. };
复制代码
为了不影响主线程接收消息,提高进行处理消息的效率,此处将消息转发的任务交给一个线程来完成。而Linux中,线程要使用一个void*(void*)范例的函数,这样就欠好将参数通报给线程了,所以要先用一个结构体将全部参数进行打包。再把指向该结构体的指针转为void*传给线程。
SendPackage是一个内部类,用于对线程所需的参数进行打包,让线程可以进行消息的转发。

  • _self:指向MessageRouter的指针,因为线程要访问全部效户,也就是访问_online_user,所以要一个指针回指来访问
  • _sockfd:套接字文件形貌符,进行消息转发也就是进行网络通讯,网络通讯依赖于套接字文件形貌符
  • _message:要转发的消息
  • _cliAddr:发送方客户端的信息
  1. static void* messageSender(void* args)
  2. {
  3.     SendPackage* sendpkg = (SendPackage*)args;
  4.     std::string msg = "[" + sendpkg->_cliAddr.ip() + ":"
  5.                         + std::to_string(sendpkg->_cliAddr.port()) + "]"
  6.                         + sendpkg->_message;
  7.     std::cout << "sending..." << msg << std::endl;
  8.     pthread_mutex_lock(&sendpkg->_self->_mutex);
  9.     for (auto& usr : sendpkg->_self->_online_user)
  10.     {
  11.         struct sockaddr_in cliaddr = usr.addr();
  12.         sendto(sendpkg->_sockfd, msg.c_str(), msg.size(), 0, (sockaddr*)&cliaddr, sizeof(cliaddr));
  13.     }
  14.     pthread_mutex_unlock(&sendpkg->_self->_mutex);
  15.     delete sendpkg;
  16.     return nullptr;
  17. }
复制代码
该函数是线程实行的函数,用于对消息进行转发,起首将参数void*转回SendPackage*,也就是刚刚的参数包结构体。
随后拼接字符串msg,这是要转发消息内容。格式为:
  1. [ip:port] 消息
复制代码
前面的[]表明这是哪一个用户发送的消息,后面是具体的消息内容。
随后服务端输出一条日记std::cout << "sending..." << msg << std::endl;,表示自己转发了这条消息。
随后访问_online_user数组,遍历全部成员,并且对通过sendto函数,进行消息转发。
转发完消息后,进行解锁,并且delete释放sendpkg,这是一个堆区上的对象,后续会讲解原因。


  • 回调主逻辑:
  1. void router(int sockfd, std::string message, InetAddr cliAddr)
  2. {
  3.     // 首次发消息 -> 注册
  4.     addUser(cliAddr);
  5.     // 用户退出
  6.     if (message == "/quit")
  7.     {
  8.         delUser(cliAddr);
  9.         return;
  10.     }
  11.     // 线程转发消息
  12.     SendPackage* sendpkg = new SendPackage(this, sockfd, message, cliAddr);
  13.     pthread_t tid;
  14.     pthread_create(&tid, 0, messageSender, messageSender);
  15.     pthread_detach(tid);
  16. }
复制代码
这个函数,就是UdpSerever中回调的函数,当用户要发消息时,起首添加该用户addUser(cliAddr),如果用户已经存在,addUser函数内部也不会重复添加。
如果用户想要退出,输入"/quit",此时会进行删除delUser。
如果前面已经添加好了用户,随后就开始进行消息转发,此时对参数进行打包new SendPackage。这里要用new创建,把这个参数包创建在堆区,因为router创建完线程,就直接退出了,此时栈区中的全部数据都会销毁。那么线程就无法读取到栈区中的参数包,所以要把参数包创建在堆区,随后让线程自己释放。
创建线程时,给线程指定函数messageSender,参数messageSender,这样线程就会去调用函数,然后完成数据的转发。
最后router退出之前,先把创建的线程detach,让其自己接纳。

main

在main.cpp中,完成全部逻辑的拼接,启动整个服务。
  1. void useage(char* argv[])
  2. {
  3.     std::cout << "useage:" << std::endl;
  4.     std::cout << "\t" << argv[0] << " + port" << std::endl;
  5. }
  6. int main(int argc, char* argv[], char* env[])
  7. {
  8.     if (argc != 2)
  9.     {
  10.         useage(argv);
  11.         return 0;
  12.     }
  13.    
  14.         // 主逻辑
  15.     return 0;
  16. }   
复制代码
起首判定用户实行该步伐的指令,用户必要指定一个端口,表示该服务使用的端口。如果没有指定,则useage输出提示用户输入一个端口号。


  • 主逻辑
  1. uint16_t port = std::stoi(argv[1]);
  2. MessageRouter msgRouter;
  3. auto func = std::bind(&MessageRouter::router, &msgRouter, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3);
  4. UdpServer udpSvr(port, func);
  5. udpSvr.start();
复制代码
当确定用户输入了一个端口后,起首用port接收这个端口,从字符串转为数字。
随后把MessageRouter的router函数作为回调函数,传给udpSvr对象。由于该函数是一个类内的函数,第一个参数为this指针。因此使用bind,把第一个参数绑定为&msgRouter,也就是一个具体对象的指针。这样新函数func的范例就与UdpServer的回调函数一致。
最后传入端口号port与回调函数func,启动服务。

Client

客户端的任务很简单,只必要完成数据的发送与接收即可。此处把发送消息和接收消息交给两个差别的线程去完成。


  • 线程参数:
  1. struct SockInfo
  2. {
  3.     SockInfo(int sockfd, const struct sockaddr_in& sockaddr)
  4.         : _sockfd(sockfd)
  5.         , _sockaddr(sockaddr)
  6.     {}
  7.     int _sockfd;
  8.     struct sockaddr_in _sockaddr;
  9. };
复制代码
由于要使用多线程,和之前也一样要把全部参数放到一个结构体一起传参。此处只必要把服务端的信息,以及通讯的套接字文件形貌符传送给线程。

  • _sockfd:与服务端通讯的文件形貌符
  • _sockaddr:服务端的套接字地址信息


  • 接收消息:
  1. void* recvMessage(void* args)
  2. {
  3.     SockInfo* sockInfo = (SockInfo*)args;
  4.     while(true)
  5.     {
  6.         char buffer[1024];
  7.         int n = recvfrom(sockInfo->_sockfd, buffer, sizeof(buffer) - 1, 0, nullptr, nullptr);
  8.         if (n > 0)
  9.         {
  10.             buffer[n] = '\0';
  11.             std::cout << buffer << std::endl;
  12.         }
  13.     }
  14.     return nullptr;
  15. }
复制代码
起首把收到的void*参数包,转回SockInfo*。
随后进入死循环,接收来自服务端的消息。此处recvfrom的最后两个参数设为nullptr,表示不关心谁发送的消息,忽略消息发送方的地址与端口信息。因为通过sockInfo->_sockfd通讯,而这个套接字就是在和服务端通讯,无需再确认身份了。
收到消息后,直接cout << buffer << endl,输出接收到的消息。


  • 发送消息:
  1. void* sendMessage(void* args)
  2. {
  3.     SockInfo* sockInfo = (SockInfo*)args;
  4.     std::string message;
  5.     while(true)
  6.     {
  7.         std::getline(std::cin, message);
  8.         
  9.         sendto(sockInfo->_sockfd, message.c_str(), message.size(),
  10.         0, (sockaddr*)&sockInfo->_sockaddr, sizeof(sockInfo->_sockaddr));
  11.     }
  12.     return nullptr;
  13. }
复制代码
同理,解析出参数包后,进入一个死循环。每轮循环等待用户输入一个消息,随后把这个消息发送给服务端,服务端会进行消息转发。


  • 主函数:
  1. void usage(char* argv[])
  2. {
  3.     std::cout << "Usage:\n\t";
  4.     std::cout << argv[0] << " server_ip server_port" << std::endl;
  5. }
  6. int main(int argc, char* argv[], char* env[])
  7. {
  8.     if (argc != 3)
  9.     {
  10.         usage(argv);
  11.         return 0;
  12.     }
  13.        
  14.         // 主逻辑
  15.        
  16.     return 0;
  17. }
复制代码
主函数中,必要用户输入一个ip和一个port,表示客户端的地址和端口,如果输入错误,调用usage提示用户。


  • 主逻辑:
  1. // 解析地址与端口
  2. std::string ip = argv[1];
  3. uint16_t port = std::stoi(argv[2]);
  4. // 创建套接字
  5. int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
  6. // 初始化服务端信息
  7. struct sockaddr_in server;
  8. bzero(&server, sizeof(server));
  9. server.sin_family = AF_INET;
  10. server.sin_addr.s_addr = inet_addr(ip.c_str());
  11. server.sin_port = htons(port);
  12. // 创建线程
  13. SockInfo sockInfo(sockfd, server);
  14. pthread_t sender;
  15. pthread_t recver;
  16. pthread_create(&sender, 0, sendMessage, &sockInfo);
  17. pthread_create(&recver, 0, recvMessage, &sockInfo);
  18. pthread_join(sender, nullptr);
  19. pthread_join(recver, nullptr);
复制代码
起首创建套接字,然后初始化服务端的信息。在sockaddr_in server中就填充了服务端的地址与端口号。
客户端无需进行bind绑定,在第一次通过sendto发送消息时,操纵体系会自动为其分配一个端口号。
随后创建两个线程,分别实行sendMessage和recvMessage,进行消息的接收与发送。
此处有一个小细节,sockInfo不是new出来的,而是直接存储在栈区的变量。这个情况与之前有所差别,之前是因为router函数创建完线程后,就直接退出了。而此处的主函数不能退出,主函数退出整个进程都终止了,所以栈区中的数据会一直存在,不必要new。
最后在main函数中通过join等待两个线程。

测试


左上角是服务端,剩余三个终端是客户端。起首右上角的终端启动,发送了一个hello,随后服务端把hello返送回给了右上角的终端。因为之前写逻辑时,只有客户端发送一次消息,服务端才会把客户端加入到_online_users中,下面两个终端没有发消息,所以服务端不知道这两个客户端存在,也就没有转发消息。

随后左下角终端发送了iammike,这个消息被转发给了右上角的终端,因为右上角的终端已经在_online_users中。

发送一段时间消息后,右上角终端输入/quit,此时服务端将其删除。最后左下角终端发送iamlisa,此时右上角终端收不到该消息了,说明右上角终端已经成功退出。


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

杀鸡焉用牛刀

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表