文章目录
- 项目演示
- 1.初步认识
- 2.数据库管理模块
- 3.用户管理模块
- 4.消息队列模块
- 5.服务端模块
- 6.消息格式
- 7.客户端
- 8.源码
项目演示
注册界面:
数据库插入数据:
登录界面:
登录成功跳转到聊天界面:
同样的有显示未读消息数的功能
添加好友:
1.初步认识
- TCP聊天系统
这是一款基于TCP的聊天系统,实现客户端与客户端之间点对点的通信 - 实现目标
实现注册、登录、添加好友、聊天功能 - 整体思想
简单来讲,就是客户端向服务端发送消息,服务端根据消息的类型对这些业务进行不同的处理,处理完成后,服务端再向另外的客户端来推送消息。那我们为什么要经过一个服务端,而不直接让两个客户端来进行通信呢?因为客户端之间互相发送消息,那么只能支持两个用户之间进行通信,因为TCP通信前要先建立连接,所以为了实现可以同时和多个人通信,我们采用客户端和服务端的方式来实现TCP通信。 - 实现示意图
客户端登录注册消息流转图
客户端添加好友消息流转图
客户端聊天消息流转图
服务端处理请求消息流转图
服务端模块划分:
2.数据库管理模块
数据库表的创建:
在此创建两张表:user、friendinfo,分别用来存放用户信息,用户好友信息。
连接mysql服务端:
使用mysql_real_connect函数,函数原型如下:
函数原型:
MYSQL *
mysql_real_connect(MYSQL *mysql, //mysql操作句柄
const char *host, //服务端IP地址
const char *user, //⽤⼾名
const char *passwd, //密码
const char *db, //数据库
unsigned int port, //端⼝
const char *unix_socket, //是否使⽤本地域套接字
unsigned long client_flag //数据库标志位, 通常为0, 采⽤默认属性
)
函数解释:连接mysql服务端,如果连接成功,返回MYSQL操作句柄,失败返回NULL。
我们创建DBServer类,封装我们数据库模块的函数。
首先是初始化函数,完成对MySQL操作句柄的初始化工作、连接到服务端并设置连接的字符集,主要是调用C API,具体代码如下:
int MysqlInit(){mysql_ = mysql_init(mysql_);if(mysql_ == NULL){return -1;}mysql_real_connect(mysql_, HOST, USER, PASSWD, DB, 3306, NULL, 0);mysql_set_character_set(mysql_, "utf8");return 0;
}
其次是专门封装了一个函数用来调用SQL语句,具体实现代码如下:
int MysqlQuery(const std::string& sql) {//执行SQL语句if (mysql_query(mysql_, sql.c_str()) != 0) {cout << mysql_error(mysql_) << endl;return false;}return true;
}
3.用户管理模块
用户管理模块首先进行数据库的初始化,主要实现对数据库的连接和设置数据库属性:
//1、实例化数据库管理模块的指针
dbs_ = new DBServer();
if (dbs_ == NULL) {return false;
}
dbs_->MysqlInit();
MysqlInit()函数是数据库管理部分提供给我们的函数。
用户管理模块主要分为两个部分,首先是对单个用户信息的描述,其次是将所有用户信息组织起来,在组织用户信息时,我们选择使用unordered_map结构来进行,该结构底层是哈希,查询速度快,适合在产品稳定后,查询请求多的情况。
首先是用户信息类,用户的信息来源于两个地方,首先是数据库中存储的已经注册过了的用户信息,我们从数据库中读出来;其次是新注册的用户,用户信息的数据来自于注册请求。
用户状态分两类:ONLINE(在线)、OFFLINE(离线)
此处把数据声明为公有,主要是为了调用时方便。
具体实现代码如下:
class UserInfo{public:UserInfo(){}UserInfo(string& nickname, string& school, string& telnum, string& passwd, int userid){nickname_ = nickname;school_ = school;telnum_ = telnum;passwd_ = passwd;user_id_ = userid;}~UserInfo(){}public:string nickname_;string school_;string telnum_;string passwd_;int user_id_;int user_status;//用户状态int tcp_sockfd_;//服务端为该用户创建的新连接套接字vector<int> friend_id_;//该用户的好友列表,用数组保存
};
在完成用户信息组织这个类中,我们有InitUserMana函数,该函数主要完成的功能是从数据库中加载已注册的用户信息,并把这些信息存放到user_map_中去。
函数实现如下:
//从数据库中加载已注册的用户信息,并存放到user_map_中去
bool InitUserMana() {//1、实例化数据库管理模块的指针dbs_ = new DBServer();if (dbs_ == NULL) {return false;}dbs_->MysqlInit();//2、查询所有的用户信息GetAllUser()unordered_map<int, vector<string>> vv;dbs_->GetAllUser(&vv);//3、遍历所有的用户信息,并且存放到user_map_中int max_id = -1;auto it = vv.begin();while (it != vv.end()) {UserInfo ui;ui.nickname_ = (it->second)[1];ui.school_ = (it->second)[2];ui.telnum_ = (it->second)[3];ui.passwd_ = (it->second)[4];ui.user_id_ = atoi((it->second)[0].c_str());ui.user_status = OFFLINE;ui.tcp_sockfd_ = -1;dbs_->GetFriendID(ui.user_id_, &ui.friend_id_);user_map_[ui.user_id_] = ui;if (ui.user_id_ > max_id) {max_id = ui.user_id_;}it++;}prepare_id_ = max_id + 1;//cout << "prepare_id : " << prepare_id_ <<endl;return true;
}
其中调用的GetAllUser函数,是数据库管理模块提供的,具体实现就是从数据库中查询,保存到结果集中,通过出参给到用户管理模块。我们获取全部用户信息是为了给用户管理模块调用从而让用户管理模块将所有的用户信息管理起来,方便对于每个用户进行操作,这样就不用每次都去数据库中查询。
代码如下:
//返回所有用户信息,参数为出参
bool GetAllUser(unordered_map<int, vector<string>>* vv) {//1、组织查询的sql语句const char* sql = "select * from user;";//2、调用查询函数if (MysqlQuery(sql) == false) {return false;};//3、获取结果集MYSQL_RES* res = mysql_store_result(mysql_);if (res == NULL) {return false;}//4、遍历结果集,保存到出参,返回给调用者int rows = mysql_num_rows(res);for (int i = 0; i < rows; ++i) {MYSQL_ROW row = mysql_fetch_row(res);//cout << row[0] << " " << row[1] << row[2] << endl;vector<string> tmp;tmp.push_back(row[0]);tmp.push_back(row[1]);tmp.push_back(row[2]);tmp.push_back(row[3]);tmp.push_back(row[4]);(*vv)[atoi(row[0])] = tmp;//形成键值对,tmp是值,row(0)中是id,就是键}mysql_free_result(res);return true;
}
用户管理实例不需要每一个对象都实例化一个出来,有一个用户管理实例来进行管理即可,所以将用户管理模块单例化:
UserMana* UserMana::um_ = NULL;
static UserMana* GetInstance(){if(um_ == NULL){g_lock_.lock();if(um_ == NULL){um_ = new UserMana();if(um_ == NULL){g_lock_.unlock();return NULL;}if(um_->InitUserMana() == false){cout<<"InitUserMana failed"<<endl;delete um_;um_ = NULL;}}g_lock_.unlock();}return um_;}
获取一个用户的所有好友信息,我们后面在每一个用户的客户端都会展示该用户的好友,并方便进行消息的发送,实现如下:
int ManaGetFri(int user_id, Json::Value* fris) {//1、参数的合法性检查if (user_id < 0) {return -1;}//2、查找用户是否存在并获取该用户信息auto it = user_map_.find(user_id);if (it == user_map_.end()) {return -2;}vector<int> fri_id = it->second.friend_id_;for (size_t i = 0; i < fri_id.size(); ++i) {Json::Value tmp;dbs_->GetFriInfo(fri_id[i], &tmp);fris->append(tmp);}return 0;//正常
}
插入用户信息,在用户注册的时候,插入用户,在其中调用了数据库管理模块的InsertUser函数
int ManaDealRegister(string& nickname, string& school,string& telnum, string& passwd) {//1、参数的有效性if (nickname == "" || school == "" || telnum == "" || passwd == "") {return -1;}//2、调用数据库管理模块的插入用户接口int pre_userid = -1;lock_map_.lock();bool ret = dbs_->InsertUser(nickname, school, telnum, passwd, prepare_id_);if (ret == false) {lock_map_.unlock();cout << "telnum 重复了" << endl;return -2;}pre_userid = prepare_id_++;lock_map_.unlock();//3、插入成功,将新用户用user_map_维护起来UserInfo ui;ui.user_id_ = pre_userid;ui.nickname_ = nickname;ui.school_ = school;ui.passwd_ = passwd;ui.telnum_ = telnum;ui.user_status = OFFLINE;user_map_[pre_userid] = ui;return pre_userid;
}
获取单个用户信息,实现如下:
int ManaGetUserInfo(int userid, UserInfo* ui) {lock_map_.lock();auto it = user_map_.find(userid);if (it == user_map_.end()) {lock_map_.unlock();return -1;}*ui = it->second;lock_map_.unlock();return 0;
}
4.消息队列模块
我们在服务端有三个消息队列,一个消息队列用于接收就绪的文件描述符,一个队列放接收到的线程,还有一个队列放要发送的线程,用STL中的队列来实现,由于queue本身线程不安全,我们将队列做了一个封装,进行加锁保护,保证线程安全。
由于不同的队列存放的对象类型不同,我们在此用模板,实现一个模板类。
这个线程安全的消息队列不支持按类型区分,如果想按类型区分,可以使用Message Queue,但是为什么不用它呢:主要是因为我服务端三个队列不需要按照类型去进行区分,只要满足先进先出就行,简单说就是用不上。
代码如下:
#pragma once
#include<queue>
#include<pthread.h>//线程安全的消息队列template <class T>
class MsgPool{public:MsgPool(){pthread_mutex_init(&lock_,NULL);pthread_cond_init(&cons_cond_,NULL);pthread_cond_init(&prod_cond_,NULL);capacity_ = 1024;}~MsgPool(){pthread_mutex_destroy(&lock_);pthread_cond_destroy(&cons_cond_);pthread_cond_destroy(&prod_cond_);}void Push(const T& msg){pthread_mutex_lock(&lock_);if(que_.size() >= capacity_){pthread_cond_wait(&prod_cond_, &lock_);//把生产者放到条件变量的等待队列上}que_.push(msg);pthread_mutex_unlock(&lock_);pthread_cond_signal(&cons_cond_);//通知消费者线程}void Pop(T* msg){pthread_mutex_lock(&lock_);if(que_.size() == 0){pthread_cond_wait(&cons_cond_, &lock_);//把消费者放到条件变量的等待队列上}*msg = que_.front();que_.pop();pthread_mutex_unlock(&lock_);pthread_cond_signal(&prod_cond_);//通知生产者线程}private:std::queue<T> que_;//保存消息的容器pthread_mutex_t lock_;//保护消息容器的锁pthread_cond_t cons_cond_;//消费者的条件变量pthread_cond_t prod_cond_;//生产者的条件变量size_t capacity_;//que_的容量
};
5.服务端模块
为什么要有一个单独的接收线程呢?为什么不让这个线程接收到请求后直接处理这些业务请求呢?因为假如某个业务十分耗时,那就需要占用这个接收线程很久的时间去处理该业务,此时如果有其他文件描述符就绪的话,就没有线程去接收它们,所以我们需要一个单独的接收线程去接收就绪的文件描述符。
接收线程:接收客户端发来的数据
static void* RecvStart(void* arg) {pthread_detach(pthread_self());//线程分离ChatSvr* cs = (ChatSvr*)arg;//1、epoll_wait 获取就绪的文件描述符while (1) {struct epoll_event arr[10];int ret = epoll_wait(cs->ep_fd_, arr, 10, -1);if (ret < 0) {continue;}//2、recvfor (int i = 0; i < ret; ++i) {char buf[10240] = { 0 };size_t r_size = recv(arr[i].data.fd, buf, sizeof(buf) - 1, 0);if (r_size < 0) {continue;}else if (r_size == 0) {epoll_ctl(cs->ep_fd_, EPOLL_CTL_DEL, arr[i].data.fd, NULL);//从epoll中移除close(arr[i].data.fd);continue;}cout << "recv msg : " << buf << endl;string msg = buf;ChatMsg cm;cm.PraseMsg(arr[i].data.fd, msg);//3、将接收的数据放到接收队列cs->recv_que_->Push(cm);}}
}
发送线程:从发送队列中获取消息然后发送给客户端
static void* SendStart(void* arg) {pthread_detach(pthread_self());ChatSvr* cs = (ChatSvr*)arg;while (1) {//1、从队列中获取元素ChatMsg cm;cs->send_que_->Pop(&cm);//2、序列化string msg;cm.GetMsg(&msg);cout << "send_msg : " << msg << endl;//3、发送send(cm.sockfd_, msg.c_str(), msg.size(), 0);}
}
工作线程:对客户端发送来的不同请求进行处理,实现如下:
static void* WorkerStart(void* arg) {pthread_detach(pthread_self());ChatSvr* cs = (ChatSvr*)arg;while (1) {//1、从队列中获取元素ChatMsg cm;cs->recv_que_->Pop(&cm);//2、按照请求类型处理不同的请求int msg_type = cm.msg_type_;switch (msg_type) {case Register:cs->DealRegister(cm);break;case Login:cs->DealLogin(cm);break;case GetFriend:cs->DealGetFriend(cm);break;case SendMsg:cs->DealSendMsg(cm);break;case AddFriend:cs->DealAddFriend(cm);break;case PushAddFriendMsg_resp:cs->DealPushAddFriendResp(cm);break;default:break;}}
}
启动服务端服务:
bool StartChatSvr() {//1、启动接收线程pthread_t tid;int ret = pthread_create(&tid, NULL, RecvStart, (void*)this);if (ret < 0) {cout << "create thread failed" << endl;return false;}//2、启动发送线程ret = pthread_create(&tid, NULL, SendStart, (void*)this);if (ret < 0) {cout << "create thread failed" << endl;return false;}//3、启动工作线程for (int i = 0; i < work_thread_count_; ++i) {ret = pthread_create(&tid, NULL, WorkerStart, (void*)this);if (ret < 0) {cout << "create thread failed" << endl;return false;}}//4、主线程进行acceptwhile (1) {int new_sockfd = accept(sockfd_, NULL, NULL);if (new_sockfd < 0) {continue;}//添加到epollstruct epoll_event ee;ee.events = EPOLLIN;ee.data.fd = new_sockfd;epoll_ctl(ep_fd_, EPOLL_CTL_ADD, new_sockfd, &ee);}
}
6.消息格式
我们为什么不直接使用Json呢?为什么要对Json进行一层封装呢?因为我们在服务端和客户端之间传递的消息不仅仅是用户发送的消息,还包括了用户请求的类型,消息的类型,所以说直接用Json作为传输的数据格式不能满足我们描述性的内容,因此,我们进行了一个简单的消息类型的封装。
在传输过程中,增加一些描述性的内容:
首先是套接字描述符,它的作用只存在在服务端,消息在服务端各个模块之间流转的时候,一直携带着服务端为某一个客户端创建的新连接套接字的值。这个值存在的意义:在发送线程往客户端发送应答的时候,发送线程就能清楚消息该发往哪个客户端。
其次是消息类型,用来描述当前消息的类型。
接着是响应状态,描述某条请求的响应状态。
客户端发送的数据,经过序列化后往服务端发送,服务端接收到后,进行反序列化后得到ChatMsg对象。
我们设置clean函数,是因为服务端收到客户端发来的ChatMsg之后,会根据请求进行应答,之后ChatMsg里面的值会发生变化,我们不需要重新申请一个ChatMsg的空间,可以直接将原ChatMsg清空,再设置新的ChatMsg的值,为什么要全部清空呢?为了防止我们修改时出错,在修改某些属性时出错。
void clear() {msg_type_ = -1;reply_status_ = -1;user_id_ = -1;json_msg_.clear();
}
我们数据在传输的时候,要进行序列化,为什么要进行序列化呢?
- 数据传输:在网络通信中,数据需要通过网络传输,序列化可以将数据转换为一种适合在网络上传输的格式
- 跨平台兼容性,该系统需要兼容Linux系统和Windows系统,通过序列化,将数据转化为一种通用的格式。
总之,通过序列化数据,可以将其转换为适合传输、存储和处理的格式,以实现跨系统、跨平台和跨语言的数据交流和操作。
在此封装了JsonUtil类,提供序列化和反序列化的接口。
该类中有两个函数,首先是Serialize函数,用于序列化它首先创建了一个写入器(Json::StreamWriter),然后使用该写入器将value对象写入到流中,最后将流中的内容转换为字符串并存储在body中。
其次是Unserialize函数,用于反序列化,它首先创建了一个字符读取器(Json::CharReader),然后使用该读取器将body字符串解析为Json::Value对象。
代码如下:
//数据的序列化和反序列化
class JsonUtil{public://value : 待要序列化的json对象//body : 序列化完毕产生的string对象,出参//序列化static bool Serialize(const Json::Value& value, string* body){Json::StreamWriterBuilder swb;//构建Json写入器//创建指向sw的unique_ptr指针,用于将Json数据写入流中unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());stringstream ss;//用于存储Json数据int ret = sw->write(value, &ss);//将value对象写入到ss流中if(ret != 0){return false;}*body = ss.str();//将ss流中的内容转换为字符串,并将结果存储在body指向的字符串中return true;}//body : 待要反序列化的string对象//value: 反序列化完毕产生的json对象,出参//反序列化static bool Unserialize(const string& body, Json::Value* value){Json::CharReaderBuilder crb;//构建Json字符读取器//创建一个指向Json::CharReader对象的unique_ptr指针,用于解析Json字符串unique_ptr<Json::CharReader> cr(crb.newCharReader());string err;//存储错误信息//将body解析为Json数据,将结果存储在value中bool ret = cr->parse(body.c_str(), body.c_str()+body.size(), value, &err);if(ret == false){return false;}return true;}
};
封装得到我们自己的消息类型,实现如下:
//消息类型
enum MsgType {Register = 0, //注册请求Register_Resp, //注册应答Login, //登录请求Login_Resp, //登录应答 3AddFriend, //添加好友请求 4AddFriend_Resp,//添加好友应答 5 PushAddFriendMsg, //推送添加好友请求 6PushAddFriendMsg_resp, //推送添加好友应答 7SendMsg, //发送数据 8SendMsg_Resp, //发送数据应答 9PushMsg, //推送数据 10PushMsg_Resp, //推送数据应答GetFriend, //获取好友信息的请求GetFriend_Resp//获取好友信息的应答 13
};//响应类型
enum ReplyStatus {REGISTER_SUCCESS = 0,//注册成功0REGISTER_FAILED, //注册失败1LOGIN_SUCCESS, //登录成功 2LOGIN_FAILED, //登录失败3ADDFRIEND_SUCCESS, //添加好友成功4ADDFRIEND_FAILED, //添加好友失败5SENDMSG_SUCCESS, //发送消息成功6SENDMSG_FAILED, //发送消息失败 7GETFRIEND_SUCCESS, //获取好友信息成功 8 GETFRIEND_FAILED //获取好友信息失败9
};class ChatMsg {
public:ChatMsg() {sockfd_ = -1;msg_type_ = -1;reply_status_ = -1;user_id_ = -1;json_msg_.clear();}ChatMsg(int msg_type, int sockfd = -1) {sockfd_ = sockfd;msg_type_ = msg_type;reply_status_ = -1;user_id_ = -1;json_msg_.clear();}~ChatMsg() {}//ChatMsg的反序列化接口,接收完毕请求后进行反序列化//sockfd来自于处理业务的时候从消息队列中获取int PraseMsg(int sockfd, string& msg) {sockfd_ = sockfd;//反序列化Json::Value tmp;JsonUtil::Unserialize(msg, &tmp);//将Json对象中的值赋值给成员变量msg_type_ = tmp["msg_type"].asInt();reply_status_ = tmp["reply_status"].asInt();user_id_ = tmp["user_id"].asInt();json_msg_ = tmp["json_msg"];return 0;}//ChatMsg的序列化接口,回复应答时使用bool GetMsg(string* msg) {Json::Value tmp;tmp["sockfd"] = sockfd_;tmp["msg_type"] = msg_type_;tmp["reply_status"] = reply_status_;tmp["user_id"] = user_id_;tmp["json_msg"] = json_msg_;return JsonUtil::Serialize(tmp, msg);}//设置Json中的kv键值对void SetKeyValue(const string& key, const string& value) {json_msg_["key"] = value;}void SetKeyValue(const string& key, int value) {json_msg_["key"] = value;}//获取key对应的value值string GetValue(const string& key) {if (!json_msg_.isMember(key)) {return "";//如果key不是Json中的有效key,返回空串}return json_msg_[key].asString();}void clear() {msg_type_ = -1;reply_status_ = -1;user_id_ = -1;json_msg_.clear();}
public:int sockfd_;//记录客户端int msg_type_;//消息类型int reply_status_;//响应状态int user_id_;Json::Value json_msg_;
};
7.客户端
客户端是用MFC实现的,只简单的用到一些基本的控件,B站上有黑马程序员的MFC讲解,有兴趣可以看看。
消息队列:不同于之前的队列,这个消息队列支持按照类型去存放,也支持按照消息类型去获取。也就是说可以按照消息类型先进先出。所以我们客户端只有一个消息队列就可以满足需求,在push和pop的时候,一定要告诉消息队列,想push和pop的是什么类型的消息。
按类型存放消息的主要设计思路就是用数组来存放队列,不同下标的队列代表不同的消息类型。vector<queue> v_msg_;这是vector加队列的容器,用回复的消息类型作为vector的下标来获取响应的回复消息类型,那么就可以从相应的队列中获取到相应的消息。
消息流转图:
客户端的代码很多都是MFC自己生成的代码,MFC可以帮助我们生成类和变量,使用的时候非常方便,我们在原有基础上添加上我们需要的业务代码即可。
8.源码
码云地址:聊翻天·林深方见鹿/项目 - 码云 - 开源中国(gitee.com)