Linux-> UDP 编程3
目录
本文说明:
一:聊天室的几个问题
1:松耦合
2:用户是什么
3:转发消息的逻辑
4:线程池怎么引入
5:客户端是两个线程
二:代码及解释
1:UdpServer.hpp(服务器类)
2:InetAddr.hpp
3:UdpClient.cc(客户端)
4:MessageRoute.hpp(消息路由类)
5:ThreadPool.hpp(线程池类)
6:Log.hpp(日志类)
三:运行效果
本文说明:
本文旨在实现UDP编程下的聊天室程序,每个人都可以在聊天室里面聊天,也可以潜水看别人发的信息,在实现的代码中不大改之前的echo程序的代码,而是让聊天功能和服务端类分离开,形成松耦合的效果~~
其中涉及到众多知识点,都在以下博客中:
socket接口的介绍及UDPecho程序:https://blog.csdn.net/shylyly_/article/details/151292001
UDP词典程序:https://blog.csdn.net/shylyly_/article/details/151576201
C++中bind:https://blog.csdn.net/shylyly_/article/details/151109228
日志文件:https://blog.csdn.net/shylyly_/article/details/151263351
线程池:https://blog.csdn.net/shylyly_/article/details/150953433
注:本博客会引入线程池,因为聊天发送的信息能被所有人看到,其实是一个转发的过程,所以我们让线程池中的多个线程去进行消息的转发,让整个程序变得更加高效优雅
一:聊天室的几个问题
1:松耦合
和之前的博客一样,我们的功能都是另外实现在一个类中的,所以聊天室的转发消息的功能实现在消息路由类(MessageRoute)中的,达到松耦合的效果
2:用户是什么
我们知道聊天的时候,起码我们需要知道是哪个用户发送的信息,所以,我们要给用一个东西标识每一个用户,索性直接使用IP+PORT来标识一个用户即可;不同IP的用户可以单单使用IP来标识,但是博主肯定是在同一个主机上进行测试的,所以用户肯定都是同一个IP上的,所以要加上端口号才能体现是不同的用户!
而怎么获取IP+PORT则很简单,我们接收到用户消息的时候,就已经从reserve函数中获取到了用户的网络属性结构体,所以我们创建一个InetAddr类,这个类的成员变量就是我们接收用户消息的同时,从reserve函数中获取到的用户的网络属性结构体,而且InetAddr类中的成员函数,可以直接返回这个网络属性结构体,也可以返回IP,也可以返回PORT。,这使得我们对一个网络属性结构体的使用更方便!而打印IP+PORT的时候,直接使用InetAddrd类的成员函数即可!
3:转发消息的逻辑
转发消息是在消息路由类(MessaeRoute)中的,转发消息函数,不能直接上来就转发消息,而是应该判断发消息给我们服务器的这个用户是否存在,不存在则需要先添加用户!
其次用户发送的信息是"Q"或者"QUIT",代表用户要退出,则需要删除掉用户!
而用户我们用一个vector来存储即可,方便遍历查找,也方便插入删除!
4:线程池怎么引入
因为松耦合,所以线程池直接引入到了消息路由类(MessageRoute)中即可! 不影响服务类代码!
其次我们知道,我们的服务类想要执行对应的功能,其实是在自己类中创建了一个回调函数,给函数是自己的构造函数的参数,所以我们可以在main.cc中创建服务类对象的时候,我们让服务类的对象的参数,也就是该函数,和消息路由类(MessageRoute)中的转发消息的函数绑定即可!
现在的问题在于,线程池呢?我们现在即使绑定了直接也是单线程的聊天室,但是我们需要的是多线程!很简单,我们只需要把转发消息函数的类型转化为线程池执行的函数的类型即可,所以仍然是绑定改变类型,只不过这里是绑定了自己类中的函数罢了,bind绑定完成之后,再入到线程池的成员变量,也就是任务队列中即可!
5:客户端是两个线程
我们之前的客户端代码都是一个线程,而这里为什么需要两个线程,完全是因为我们任何一个用户,都应该不仅可以发消息,还可以接收消息!
虽然之前也可以发消息和接收消息,但是,不能同时并发的进行发信息和接受信息,而是串行进行的,并且串行的途中,会被阻塞!
阻塞点1:std::getline(std::cin, message)
等待用户输入的这一步骤是阻塞的,你不输入,则永远不会进行下一步的接收服务器的功能!其次
阻塞点2:recvfrom(...)
等待服务器回复也是阻塞的,在此期间,用户无法输入想发送的新消息
所以,我们客户端中是双线程,一个只负责发送信息,一个只负责接收信息,才能实现我们平时使用聊天软件的感觉!
二:代码及解释
1:UdpServer.hpp(服务器类)
#pragma once#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <functional>
#include "Log.hpp"
#include "InetAddr.hpp"// 一些发生错误时候 返回的枚举变量
enum
{SOCKET_ERROR = 1,BIND_ERROR,USAGE_ERROR
};// socket接口默认的返回值
const static int defaultfd = -1;// 定义一个函数类型 别名为hander_message_t
using hander_message_t = std::function<void(int sockfd, const std::string message, const InetAddr who)>;class UdpServer
{
public:// 构造函数 socket的返回值 端口号 是否运行 转发消息函数UdpServer(uint16_t port, hander_message_t hander_message) : _sockfd(defaultfd), _port(port), _isrunning(false), _hander_message(hander_message){}// 初始化服务端void InitServer(){// 1:创建udp socket 套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);// 创建套接字失败 打印语句提醒if (_sockfd < 0){LOG(FATAL, "socket error, %s, %d\n", strerror(errno), errno);exit(SOCKET_ERROR);}// 创建套接字成功 打印语句提醒LOG(INFO, "socket create success, sockfd: %d\n", _sockfd);// 2 填充sockaddr_in结构struct sockaddr_in local; // 网络通信 所以定义struct sockaddr_in类型的变量bzero(&local, sizeof(local)); // 先把结构体清空 好习惯local.sin_family = AF_INET; // 填写第一个字段 (地址类型)local.sin_port = htons(_port); // 填写第二个字段PORT (需先转化为网络字节序)local.sin_addr.s_addr = INADDR_ANY; // 填写第三个字段IP (直接填写0即可,INADDR_ANY就是为0的宏)// 3 bind绑定// 我们填充好的local和我们创建的套接字进行绑定(绑定我们接收信息发送信息的端口)int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));// 绑定失败 打印语句提醒if (n < 0){LOG(FATAL, "bind error, %s, %d\n", strerror(errno), errno);exit(BIND_ERROR);}// 绑定成功 打印语句提醒LOG(INFO, "socket bind success\n");}// 启动服务端(不想让网络通信模块和业务模块进行强耦合)void Start(){// 先把bool值置为true 代表服务端在运行_isrunning = true;while (true) // 服务端都是死循环{char message[1024]; // 对方端发来的信息 存储在message中struct sockaddr_in peer; // 对方端的网络属性socklen_t len = sizeof(peer); // 必须初始化成为sizeof(peer) 不能初始化为0// 我们要让server先收数据ssize_t n = recvfrom(_sockfd, message, sizeof(message) - 1, 0, (struct sockaddr *)&peer, &len);if (n > 0){message[n] = 0;// 添加字符串终止符 方便得到正确完整的单词//创建对象addr 使用 用户(客户端)的网络属性结构体作为InetAddr类的对象的构造参数//所以我们的用户本质就是一个个的网络属性结构体InetAddr addr(peer);//打印日志:"get message from [用户IP:用户PORT]:消息 LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.Ip().c_str(), addr.Port(), message);//执行转发信息的函数(本质会被插入到线程池中的任务队列 线程池就会去执行)_hander_message(_sockfd, message, addr);}}_isrunning = false;}// 析构函数~UdpServer(){}private:int _sockfd; // socket的返回值 在多个接收中都需要使用uint16_t _port; // 服务器所用的端口号bool _isrunning; // 反映是否在运行的bool值// 给服务器设定回调,用来执行转发信息的函数hander_message_t _hander_message;
};
解释:
服务器类的大体逻辑不变,只不过我们的回调函数的类型较我们上篇博客的字典程序改变了
如下:
// 定义一个函数类型 别名为hander_message_t fd 用户发送的信息 用户的网络属性结构体
using hander_message_t = std::function<void(int sockfd, const std::string message, const InetAddr who)>;
其中的三个参数,也很好理解:
第一个fd:肯定是要的,因为转发消息的本质就是使用sendto,所以fd一定是我们服务器类开辟网络端口的fd!
第二个message:message就是用户发送的消息,既然要转发消息,那肯定要告诉回调函数转发的消息是啥
第三个who:是InetAddr类的对象,InetAddr类呢,上文说过,其成员变量是一个用户网络属性结构体,为什么不直接传我们reserve函数中的peer呢,而是非要InetAddr addr(peer);这是因为,我们的InetAddr类中可以轻松的获取网络属性结构体的IP或PORT,甚至整个网络属性结构体。而peer不可以!
当然这个hander_message_t 函数是需要绑定到消息路由类(MessageRoute)中的某个函数的,这是udp编程的通用方式,回调函数,再把回调函数绑定到另一个类中的成员函数,达到松耦合
2:InetAddr.hpp
InetAddr.hpp就是我们的InetAddr类所在文件,该类接收一个用户的网络属性结构体,成员函数可以返回网络属性结构体或者其中的IP和PORT!
在我们想要打印某个用户的网络属性结构体中的IP和PORT,也就是标识用户的时候,就需要用到这个类的成员函数!
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>// 网络属性类 (该类可以返回某个用户对应的网络属性中的 IP 或P ORT 或直接返回网络属性结构体)
class InetAddr
{
private:// 私有方法(获取用户的IP 和 PORT)void GetAddress(std::string *ip, uint16_t *port){// 通通需要转网络字节序*port = ntohs(_addr.sin_port); // 存储进了成员变量_ip中*ip = inet_ntoa(_addr.sin_addr); // 存储进了成员变量_port中}public:// 构造函数InetAddr(const struct sockaddr_in &addr) : _addr(addr){GetAddress(&_ip, &_port); // 内部直接调用私有方法 GetAddress(获取用户的IP 和 PORT) 方便之后可以直接获取属性}// 获取用户的IPstd::string Ip(){return _ip;}// 重载InetAddr类的==符号// 用于判断用户是否在存储用户的vector中bool operator==(const InetAddr &addr){//比较ip和port 同时相等 才认为存在!if (_ip == addr._ip && _port == addr._port){return true;}return false;}// 获取用户的网络属性结构体struct sockaddr_in Addr(){return _addr;}// 获取用户的PORTuint16_t Port(){return _port;}// 析构函数~InetAddr(){}private:struct sockaddr_in _addr; // 成员变量_addr 用于接收一个网络属性结构体std::string _ip; // 成员变量_ip 用来存储用户的IPuint16_t _port; // 成员变量_port 用来存储用户的PORT
};
3:UdpClient.cc(客户端)
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <thread>
#include <functional>void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " serverip serverport\n"<< std::endl;
}//初始化客户端
int InitClient(std::string &serverip, uint16_t serverport, struct sockaddr_in *server)
{// 1. 创建socketint sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;return -1;}// 构建服务端的socket信息memset(server, 0, sizeof(struct sockaddr_in));server->sin_family = AF_INET;server->sin_port = htons(serverport);server->sin_addr.s_addr = inet_addr(serverip.c_str());return sockfd;
}//接收函数
void recvmessage(int sockfd, std::string name)
{while (true){struct sockaddr_in peer;socklen_t len = sizeof(peer);char buffer[1024];ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (n > 0){buffer[n] = 0;fprintf(stderr, "[%s]%s\n", name.c_str(), buffer); // stderr}}
}//发送函数
void sendmessage(int sockfd, struct sockaddr_in server, std::string name)
{std::string message;while (true){printf("%s | Enter# ", name.c_str());fflush(stdout);std::getline(std::cin, message);sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));}
}int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);struct sockaddr_in serveraddr;//初始化客户端 返回创建套接字的fd fd用于下面的接收和发送函数的参数int sockfd = InitClient(serverip, serverport, &serveraddr);if (sockfd == -1)return 1;// 创建接收线程std::thread recvThread(recvmessage, sockfd, "recver");// 创建发送线程std::thread sendThread(sendmessage, sockfd, serveraddr, "sender");// 等待线程结束recvThread.join();sendThread.join();return 0;
}
解释:正如我们前文所言,聊天室的客户端是两个线程,一个负责发送信息,一个负责接收消息,才能达到并发的效果,不会发生堵塞的现象!
4:Main.cc
Main.cc(main.cc)是完善服务器的一个文件,因为我们服务器还需要bind等操作,所以单独写在一个文件中
#include <iostream>
#include <memory>
#include "MessageRoute.hpp"
#include "UdpServer.hpp"// 运行服务端的时候,因为IP在服务端文件中给定0了
// 所以使用者只需要给服务端一个PORT即可
void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " local_port\n"<< std::endl;
}int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);exit(USAGE_ERROR);}EnableScreen(); // 表示把日志打印到屏幕上// 定义消息转发模块的对象MessageRoute route;// 网络部分uint16_t port = std::stoi(argv[1]); // 从main的参数列表中获取到PORT ---> 用于作为服务端类的构造函数的参数// 创建服务端对象 参数中的函数是bind到MessageRoute(消息路由类)中的成员函数Route的!std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, std::bind(&MessageRoute::Route, &route, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); // C++14usvr->InitServer();usvr->Start();return 0;
}
解释:
重点在于理解bind,我们的服务器类的回调函数hander_message_t ,这个hander_message_t 函数肯定就是转发消息的核心所在,可以看到我们的main.cc中,把MessageRoute类中的Route函数绑定bind到了我们服务器类的hander_message_t 上!这就是松耦合的灵魂所在了,依旧是服务器类选择回调函数,回调的函数是bind的另一个类中的函数!
5:MessageRoute.hpp(消息路由类)
MessageRoute.hpp(消息路由类)就是聊天室的灵魂所在了,转发消息的功能都在这个类中,并且锁的使用方式有两种,注释的一种和没注释的一种,都可以!
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <vector>
#include <sys/types.h>
#include <sys/socket.h>
#include <functional>
#include <thread>
#include <mutex>
#include "InetAddr.hpp"
#include "ThreadPool.hpp"// 该类型是线程池代码中线程执行的函数的类型 也就是线程池中消息队列的元素
// 因为我们需要入任务(函数)进线程池的任务队列 所以我们先定义出类型void()
using task_t = std::function<void()>;// 消息路由类
// 该类负责转发消息
class MessageRoute
{
private:// 私有函数 查看用户是否存在bool IsExists(const InetAddr &addr){for (auto a : _online_user){if (a == addr)return true;}return false;}public:// 构造函数MessageRoute(){//pthread_mutex_init(&_mutex, nullptr); // 初始化锁}// 增加用户void AddUser(const InetAddr &addr){// pthread_mutex_lock(&_mutex); // 申请锁std::lock_guard<std::mutex> lock(_mutex); // 自动加锁,函数结束时自动解锁if (IsExists(addr)) {// pthread_mutex_unlock(&_mutex); // 释放锁return;} // 该用户已经存在 则返回_online_user.push_back(addr); // 不存在 则插入到vector中// pthread_mutex_unlock(&_mutex); // 释放锁}// 删除用户void DelUser(const InetAddr &addr){// pthread_mutex_lock(&_mutex); // 申请锁std::lock_guard<std::mutex> lock(_mutex); // 自动加锁,函数结束时自动解锁// 遍历vector 查找需要删除的用户for (auto iter = _online_user.begin(); iter != _online_user.end(); iter++){// 发现删除的用户if (*iter == addr){_online_user.erase(iter); // 则使用erase接口从vector中删除用户break;}}// pthread_mutex_unlock(&_mutex); // 释放锁}// 消息转发函数void RouteHelper(int sockfd, std::string message, InetAddr who){// pthread_mutex_lock(&_mutex); // 申请锁std::lock_guard<std::mutex> lock(_mutex); // 自动加锁,函数结束时自动解锁// 进行消息转发for (auto u : _online_user) // 遍历vector向每个用户转发消息{// 转发的消息的格式如下:// 发消息的用户的[IP + PORT] + 消息std::string send_message = "\n[" + who.Ip() + ":" + std::to_string(who.Port()) + "]# " + message + "\n";// 获取到每个用户的网络属性结构体struct sockaddr_in clientaddr = u.Addr(); // u是InetAddr类的对象 所以可以直接调用InetAddr类的方法Addr()获取到网络属性结构体// 向每个用户进行发送:[IP + PORT] + 消息::sendto(sockfd, send_message.c_str(), send_message.size(), 0, (struct sockaddr *)&clientaddr, sizeof(clientaddr));}// pthread_mutex_unlock(&_mutex); // 申请锁}// 这是被bind到服务类的函数// 也就是每次有用户(客户端)发信息,则服务类就会调用这个函数void Route(int sockfd, std::string message, InetAddr who){// 用户首次发消息,不光光是发消息,还要将自己,插入到在线用户vector中!AddUser(who);// 如果用户发送的信息"Q"或者"QUIT" 代表客户要退出if (message == "Q" || message == "QUIT"){DelUser(who); // 则我们从vector中删除掉该用户}// 构建任务对象(函数),用来插入线程池的任务队列,就能让线程池执行该函数// 我们线程池中执行任务队列是直接t(),所以我们需要把RouteHelper函数bind为void()类型 才能不修改线程池的代码 直接t()task_t t = std::bind(&MessageRoute::RouteHelper, this, sockfd, message, who);// 入线程池的任务队列,线程池内部会执行该函数 达到转发的效果ThreadPool<task_t>::GetInstance().Push(t);}// 析构函数~MessageRoute(){//pthread_mutex_destroy(&_mutex);}private:std::vector<InetAddr> _online_user; // 存储用户的vector// pthread_mutex_t _mutex; // 锁std::mutex _mutex; // 使用 std::mutex 替代 pthread_mutex_t
};
解释:
可以看见,MessageRoute.hpp(消息路由类)中的成员函数可以查看用户是否存在(IsExists),添加用户(AddUser),删除用户(DelUser),以及消息转发函数(RouteHelper)。
但是这些都是分散的,我们想要完成一次真正的消息转发函数,我们应该现检测用户是否存在(IsExists),不存在添加用户(AddUser),以及用户发送的信息是什么,如果是"Q"或"QUIT"则应该删除用户(DelUser),昨晚这些之后,才进行消息转发函数(RouteHelper)!
所以这就是为什么我们有一个Route函数,Route函数就是把我们第二段话的逻辑整合起来罢了,当我们在Route函数中完成了前置工作后,此时我们才会需要调用消息转发函数(RouteHelper)进行消息转发,而我们是线程池下的聊天室,所以我们的消息转发函数(RouteHelper)不能直接调用,否则就是单线程!
所以我们要把消息转发函数(RouteHelper)添加到线程池的任务队列中!这就意味着我们需要先保持消息转发函数(RouteHelper)和线程池中的任务队列中的任务(函数)的类型一致性,而我们的线程池中的函数都是void()类型的,也就是没有参数,返回值为void,直接调用!而RouteHelper的类型不是void(),所以bind即可解决问题!
如下:
//MessageRoute.hpp(消息路由类)中定义的类型
using task_t = std::function<void()>;//bind消息转发函数(RouteHelper)让其成为task_t类型
task_t t = std::bind(&MessageRoute::RouteHelper, this, sockfd, message, who);
这样我们的t就是我们线程池需要的类型了,所以下面直接入线程池的成员变量任务队列中 即可:
//把t任务入线程池的任务队列中
ThreadPool<task_t>::GetInstance().Push(t);
而线程池以及日志,就不再赘述了,都在一开始的本文说明中的单独博客中详细讲解过了!
6:ThreadPool.hpp(线程池类)
pragma once#include <iostream>
#include <unistd.h>
#include <queue>
#include <pthread.h>
#include <mutex> // 用于单例模式的线程安全#define NUM 5 // 线程池默认的线程数目template <class T>
class ThreadPool
{
private:// 私有构造函数,实现单例模式ThreadPool(int num = NUM): _thread_num(num), _stop(false) // 添加停止标志{pthread_mutex_init(&_mutex, nullptr); // 初始化锁pthread_cond_init(&_cond, nullptr); // 初始化条件变量ThreadPoolInit(); // 初始化线程池}// 禁止拷贝和赋值ThreadPool(const ThreadPool &) = delete;ThreadPool &operator=(const ThreadPool &) = delete;// 判断任务队列是否为空bool IsEmpty(){return _task_queue.size() == 0;}// 申请锁(访问任务队列之前使用)void LockQueue(){pthread_mutex_lock(&_mutex);}// 释放锁(访问完任务队列之后使用)void UnLockQueue(){pthread_mutex_unlock(&_mutex);}// 线程等待void Wait(){pthread_cond_wait(&_cond, &_mutex);}// 唤醒线程void WakeUp(){pthread_cond_signal(&_cond);}// 唤醒所有线程void WakeUpAll(){pthread_cond_broadcast(&_cond);}public:// 析构函数~ThreadPool(){Stop(); // 停止线程池pthread_mutex_destroy(&_mutex); // 销毁锁pthread_cond_destroy(&_cond); // 销毁条件变量}// 获取单例实例的静态方法static ThreadPool &GetInstance(int num = NUM){static ThreadPool instance(num); // 线程安全的懒汉初始化return instance; // 返回唯一的线程池对象}// 初始化线程池// 创建_thread_num个线程void ThreadPoolInit(){pthread_t tid;for (int i = 0; i < _thread_num; i++){pthread_create(&tid, nullptr, Routine, this);}}// 添加任务void Push(const T &task){LockQueue();_task_queue.push(task);UnLockQueue();WakeUp(); // 唤醒一个线程去处理任务}// 任务出队列void Pop(T &task){task = _task_queue.front();_task_queue.pop();}// 停止线程池// 本质是唤醒所有的线程去执行完任务队列中残留的任务void Stop(){LockQueue();_stop = true; // 设置停止标志WakeUpAll(); // 唤醒所有等待的线程UnLockQueue();}// 线程执行的函数static void *Routine(void *arg){pthread_detach(pthread_self());ThreadPool *self = (ThreadPool *)arg;while (true){self->LockQueue(); // 访问任务队列前申请锁// 线程需要等待// 条件:队列不为空 或 线线程池被叫停 线程才会等待while (self->IsEmpty() && !self->_stop){// std::cout << "while (self->IsEmpty() && !self->_stop)\n";self->Wait();}// 线程需要退出// 条件:线程池被叫停 或 任务队列为空if (self->_stop && self->IsEmpty()){// std::cout << "if (self->_stop && self->IsEmpty())\n";self->UnLockQueue();break; // 退出线程}// 来到这 代表线程需要执行任务队列中的任务T task; // 创建取出任务队列中的任务self->Pop(task); // 取出队首任务 并且 从任务队列中去掉该任务self->UnLockQueue();task(); // 运行任务}return nullptr;}private:std::queue<T> _task_queue; // 任务队列int _thread_num; // 线程数目pthread_mutex_t _mutex; // 锁pthread_cond_t _cond; // 条件变量bool _stop; // 停止标志
};
7:Log.hpp(日志类)
#pragma once#include <iostream> //C++必备头文件
#include <cstdio> //snprintf
#include <string> //std::string
#include <ctime> //time
#include <cstdarg> //va_接口
#include <sys/types.h> //getpid
#include <unistd.h> //getpid
#include <thread> //锁
#include <mutex> //锁
#include <fstream> //C++的文件操作std::mutex g_mutex; // 定义全局互斥锁
bool gIsSave = false; // 定义一个bool类型 用来判断打印到屏幕还是保存到文件
const std::string logname = "log.txt"; // 保存日志信息的文件名字// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};// 将日志写进文件的函数
void SaveFile(const std::string &filename, const std::string &message)
{std::ofstream out(filename, std::ios::app);if (!out.is_open()){return;}out << message << std::endl;out.close();
}// 日志等级转字符串--->字符串才能表示等级的意义 0123意义不清晰
std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}// 获取当前时间的字符串
// 时间格式包含多个字符 所以干脆糅合成一个字符串
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1, // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday, // tm_mday: 月中的日期(1-31)format_time->tm_hour, // tm_hour: 小时(0-23)format_time->tm_min, // tm_min: 分钟(0-59)format_time->tm_sec); // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, bool issave, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 保存格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息 到message中std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer;// 打印到屏幕if (!issave){std::cout << message << std::endl;}// 保存进文件else{SaveFile(logname, message);}
}// 宏定义 省略掉__FILE__ 和 __LINE__
#define LOG(level, format, ...) \do \{ \LogMessage(level, __FILE__, __LINE__, gIsSave, format, ##__VA_ARGS__); \} while (0)// 用户调用则意味着保存到文件
#define EnableScreen() (gIsSave = false)// 用户调用则意味着打印到屏幕
#define EnableFile() (gIsSave = true)
三:运行效果
解释:
①:我们的客户端不仅可以发送信息,还可以在自己的界面看见其他客户发送的消息
②:每个用户都有自己的名字, 也就是IP+PORT,这可以区分同一个主机上的不同端口号用户
③:当用户没有发送消息的时候,是看不见聊天室的消息的,因为此时该用户类似还没有登录,你当然看不到别人发的消息!所以第一次发消息的时候,其实就是登录的过程,并且你第一次发送的信息,别人肯定也是可以看到的!就好比一个群的新成员进群,看不到之前的消息,但是其他群成员可以看到你发的第一条消息!
最后,如果你想测试不同主机像你主机上的服务器发送消息的过程,此时你的主机上的服务器会受到所有的消息,而其他调用客户端的主机则会看到聊天室的所有信息!所以其他人运行客户端的主机,只需要有你的客户端的代码即可!然后运行起来,IP不写环回地址,而是服务器主机的IP即可!