目录
码云地址:聊天室 · 月半木斤/项目 - 码云 - 开源中国 (gitee.com)
项目演示:
首先来演示一下我们的项目最终完工的样子:
TCP聊天室系统功能展示
1.整体思想
对于聊天室这个项目的整体思想很简单就是客户端向服务端来发送消息由服务端来处理业务向另外的客户端来推送消息,那么我们为什么不能直接两个客户端之间来进行通信呢?这个原因很简单因为如果客户端之间互相发送消息那么只能支持两个用户之间通信,因为tcp通信之前要先建立连接。所以这里我们采用客户端和服务端的方式来实现tcp通信。项目整体的流程图:
2.数据库管理模块
数据库管理模块主要为用户管理模块提供操作数据库的接口函数。因为我们的数据库就是用来存放数据的。那么数据库管理模块只要可以将数据库中的数据进行操作,查询等等就完成了数据库管理模块的任务。所以我们先来看一些关于数据库操作的函数。
1. 数据库表的创建
这里创建了三张表:user,friendinfo,buf。分别用来存放用户信息,用户好友信息,服务端下线缓存信息。
2.对于数据库的操作
对于在代码中对于数据库的操作本有以下几个方面:首先包含头文件
2.1 对数据库连接
2.2 数据库语句执行
2.3 获取查询数据库的结果集
(简而言之就是将你刚刚对数据库操作的结果保存到一个空间中,然后我们可以对其结果进行一系列的处理,这里可以类比你在数据库中执行了当前语句,然后对于你查询的结果进行操作)
这些就是本项目中用到的一些关于数据库的基本操作。
2.json数据格式
在本项目中数据的组织形式采用了json的格式。这里简单介绍一下json(真的是简单介绍,本人也只懂一个皮毛)
这是百度百科对于json的描述:JSON(JavaScript Object Notation, JS对象简谱)是一种轻量级的数据交换格式。它基于 ECMAScript(European Computer Manufacturers Association, 欧洲计算机协会制定的js规范)的一个子集,采用完全独立于编程语言的文本格式来存储和表示数据。简洁和清晰的层次结构使得 JSON 成为理想的数据交换语言。 易于人阅读和编写,同时也易于机器解析和生成,并有效地提升网络传输效率。我对于json的理解json就是一个对象。而且这个对象中可以以键值对的方式来任意存储任何类型的数据。包括自己也就是json对象。那么这里来简单的
json对象由'{}'包裹,数组由[],我们来几个例子演示一下:
添加对象:数组形式:
这就是最基本的格式,当然json中也提供了许多成员函数,包括size等等和STL中的容器类似。这里读者可以自行实验。多种组合。本项目中我们只用到其简单的组织数据。所以掌握这些知识足以。那么接下来就是几个模块的代码
3.具体代码实现
3.1用户管理模块
1.初始化数据库:主要实现对于数据库的连接和设置数据库属性
bool MysqlInit(){
mysql_ = mysql_init(NULL);
if (mysql_ == NULL){
std::cout << "mysql init failed" << std::endl;
return false;
}
if (mysql_real_connect(mysql_, HOST, USER, PASSWD, DB, DBPORT, NULL, 0) == NULL){
std::cout << "msyql connect failed" << std::endl;
mysql_close(mysql_);
return false;
}
mysql_set_character_set(mysql_, "utf8");
return true;
}
2.获取全部用户信息:这里我们获取全部好友信息是为了给用户管理模块进行调用从而让用户管理模块将所有的用户信息管理起来,方便对于每个用户进行操作。
bool GetAllUser(Json::Value* all_user){
#define GETALLUSER "select * from user;"
lock_.lock();
//这里查询结果集的时候如果有多个线程同时调用不同的函数那么查询的时候结果集中可能就保存的不是我们想要的
//1.数据库查询
if (MysqlQuery(GETALLUSER) == false){
lock_.unlock();
return false;
}
//2.获取结果集
MYSQL_RES* res = mysql_store_result(mysql_);
if (res == NULL){
lock_.unlock();
return false;
}
lock_.unlock();
//3.获取单行数据
int row_nums = mysql_num_rows(res);
for (int i = 0; i < row_nums; i++){
MYSQL_ROW row = mysql_fetch_row(res);
//4.将单行数据按照格式, 组织起来。 传递给调用者
Json::Value tmp;
tmp["userid"] = atoi(row[0]);
tmp["nickname"] = row[1];
tmp["school"] = row[2];
tmp["telnum"] = row[3];
tmp["passwd"] = row[4];
all_user->append(tmp);
}
mysql_free_result(res);
return true;
}
获取一个用户的所有好友信息,这里我们获取一个用户的所有好友信息是为了在后面的客户端模块来和好友发送消息,并展示。
bool GetFriend(int userid, std::vector<int>* f_id){
#define GETFRIEND "select friend from friendinfo where userid='%d';"
//1.格式化sql语句
char sql[1204] = { 0 };
sprintf(sql, GETFRIEND, userid);
lock_.lock();
//2.查询
if (MysqlQuery(sql) == false){
lock_.unlock();
return false;
}
//3.获取结果集
MYSQL_RES* res = mysql_store_result(mysql_);
if (res == NULL){
lock_.unlock();
return false;
}
lock_.unlock();
//4.获取单行数据
int row_nums = mysql_num_rows(res);
for (int i = 0; i < row_nums; i++){
MYSQL_ROW row = mysql_fetch_row(res);
f_id->push_back(atoi(row[0]));
}
mysql_free_result(res);
return true;
}
插入用户信息:用于用户注册的时候插入用户的
bool InsertUser(int userid, const std::string& nickname
, const std::string& school, const std::string& telnum
, const std::string& passwd){
#define INSERTUSER "insert into user(userid, nickname, school, telnum, passwd) values('%d', '%s', '%s', '%s', '%s');"
char sql[1024] = { 0 };
sprintf(sql, INSERTUSER, userid, nickname.c_str(), school.c_str(), telnum.c_str(), passwd.c_str());
std::cout << "Insert User: " << sql << std::endl;
//2.这里执行插入语句
if (MysqlQuery(sql) == false){
return false;
}
return true;
}
- 添加好友,将当前用户和要添加的好友的用户都插入好友信息表中维护两人的好友关系。
bool InsertFriend(int userid1, int userid2){ #define INSERTFRIEND "insert into friendinfo values('%d', '%d');" char sql[1024] = { 0 }; sprintf(sql, INSERTFRIEND, userid1, userid2); //2.插入语句 if (MysqlQuery(sql) == false){ return false; } return true; }
- 数据库操作函数:将要执行的命令传入即可对数据库进行相应的操作。
bool MysqlQuery(const std::string& sql){ if (mysql_query(mysql_, sql.c_str()) != 0){ std::cout << "exec failed sql: " << sql << std::endl; return false; } return true; }
-
3.2消息队列
- 这里的消息队列很简单就是将消息要发送的或者要接收的,数据存入一个线程安全队列中,如果要获取那么就获取,如果要插入就插入。底层用STl库中的队列来实现,对于队列的入队和出队都加锁保护。
#include<queue> #include<pthread.h> #include<iostream> #define CAPACITY 10000 //线程安全队列用来存放从客户端发送来的消息或是从服务端发送来的消息 // using std::queue; using std::cout; using std::endl; template<class T> class MsgQueue { public: MsgQueue() { _capacity=CAPACITY; pthread_mutex_init(&con_lock,NULL); pthread_cond_init(&cons_cond,NULL); pthread_cond_init(&prod_cond,NULL); } ~MsgQueue() { pthread_mutex_destroy(&con_lock); pthread_cond_destroy(&cons_cond); pthread_cond_destroy(&prod_cond); } void Push(const T& msg) { pthread_mutex_lock(&con_lock); //这里用循环而不用if判断是因为下面这种情况:当生产者线程A被唤醒后,同时生产者b也被唤醒了那么他们都要执行push语句,那么这里如果容量已经是CAPACIT-1那么生成者线程B先push那么A再往队列中进行插入那么就会导致容量越界。 while(_con.size()>=_capacity) { pthread_cond_wait(&prod_cond,&con_lock); } _con.push(msg); pthread_cond_signal(&cons_cond);//唤醒消费者线程 pthread_mutex_unlock(&con_lock);//这里如果先唤醒然后在解锁会怎么样?????????????????好像不会怎么样 pthread_cond_signal(&cons_cond);//唤醒消费者线程 } void Pop(T* msg) { pthread_mutex_lock(&con_lock); while(_con.empty()) { pthread_cond_wait(&cons_cond,&con_lock); } *msg=_con.front(); _con.pop(); pthread_mutex_unlock(&con_lock);//这里如果先唤醒然后在解锁会怎么样?????????????????好像不会怎么样 pthread_cond_signal(&prod_cond);//唤醒消费者线程 } bool Empty() { return _con.empty(); } private: queue<T> _con; size_t _capacity; pthread_mutex_t con_lock;//线程锁 pthread_cond_t cons_cond;//消费者条件变量 pthread_cond_t prod_cond;//生产者条件变量 };
-
3.3 数据格式
- 这里的数据格式是一个类,成员变量有四个,sockfd_用来保存数据要发送方的套接字,msg_type_标明了当前的消息类型,user_id_不是固定的这里需要根据场景自己来组织user_id_,reply_status_用来标明回复的状态主要用于给客户端回复消息使用。json_msg_是json对象自由度最高的一个成员变量,可以根据消息的用途来自定义消息。
#pragma once #include <string> #include <memory> #include <sstream> #include <jsoncpp/json/json.h> #define TCP_DATA_MAX_LEN 1024 #define TCP_PORT 38989 //当前文件用来实现发送消息的格式: enum chat_msg_type{ Register = 0, //0, 注册请求 Register_Resp, //1, 注册应答 Login, //2. 登录请求 Login_Resp, //3, 登录应答 AddFriend, //4, 添加好友请求 AddFriend_Resp, //5, 添加好友请求应答 SendMsg, //6, 发送消息 SendMsg_Resp, //7, 发送消息应答 PushMsg, //8, 推送消息 PushMsg_Resp, //9, 推送消息应答 PushAddFriendMsg, //10, 推送添加好友请求 PushAddFriendMsg_Resp, //11, 推送添加好友请求的应答 GetFriendMsg, //12, 获取全部好友信息 GetFriendMsg_Resp, //13, 获取全部好友信息应答 SetUserOffLine //14 //后续如果要增加业务, 可以在后面增加其他的消息类型 }; enum reply_status{ REGISTER_SUCCESS = 0, //0, 注册成功 REGISTER_FAILED, //1,注册失败 LOGIN_SUCESSS, //2, 登录成功 LOGIN_FAILED, //3, 登陆失败 ADDFRIEND_SUCCESS, //4, 添加好友成功 ADDFRIEND_FAILED, //5, 添加好友失败 SENDMSG_SUCCESS, //6, 发送消息成功 SENDMSG_FAILED, //7, 发送给消息失败 GETFRIEND_SUCCESS, //8,获取好友列表成功 GETFRIEND_FAILED //9, 获取好友列表失败 }; /* * 注册请求的消息格式 * 注册的时候我们需要将个人用户的所有信息都获取得到 * sockfd_ (消息达到服务端之后, 由服务端接收之后, 打上sockfd_) * msg_type_ : Register * json_msg: { * nickname : 'xxx' * school : "xxx" * telnum : "xxxx" * passwd : "xxxx" * } * * 注册的应答: * msg_type_ : Register_Resp * reply_status_ = REGISTER_SUCCESS / REGISTER_FAILED * 如果是REGISTER_SUCCESS : [user_id_] * * * * 登录的请求消息格式 * sockfd_ (消息达到服务端之后, 由服务端接收之后, 打上sockfd_) * msg_type_ : Login * json_msg_ : { * telnum : xxx * passwd : xxx * } * * 登录的应答: * msg_type : Login_Resp; * reply_status_ : LOGIN_SUCCESS/LOGIN_FAILED * 如果是LOGIN_SUCCESS : [user_id_] * * * * 添加好友请求: * msg_type_ : AddFriend * json_msg_ :{ * fri_tel_num : xxxx * } * * * 推送添加好友的请求 * msg_type : PushAddFriendMsg * sockfd_ : 被添加方的套接字描述符 * json_msg_: { * adder_nickname : xxx * adder_school : xxx * adder_userid : xxx * } * * 推送添加好友的应答(被添加方发送给服务端的) * msg_type : PushAddFriendMsg_Resp * user_id : 被添加方的id * reply_status : ADDFRIEND_SUCCESS / ADDFRIEND_FAILED * 如果说是ADDFRIEND_SUCCESS * json_msg_ : 添加方的id * * 添加好友的应答: * msg_type: AddFriend_Resp * reply_status : ADDFRIEND_FAILED / ADDFRIEND_SUCCESS * 如果是成功:ADDFRIEND_SUCCESS * json_msg_ : * BeAdd_nickname : 被添加方的名字 * BeAdd_school : 被添加方的学校 * BeAdd_userid : 被添加方的id * */ class JsonUtil{ public: /* * 用来序列化我们要发送的json串将其序列化为string类 * */ static bool Serialize(const Json::Value& value, std::string* body) { Json::StreamWriterBuilder swb; std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter()); std::stringstream ss; int ret = sw->write(value, &ss); if (ret != 0) { return false; } *body = ss.str(); return true; } /* * 反序列化 * */ static bool UnSerialize(const std::string& body, Json::Value* value) { Json::CharReaderBuilder crb; std::unique_ptr<Json::CharReader> cr(crb.newCharReader()); std::string err; bool ret = cr->parse(body.c_str(), body.c_str() + body.size(), value, &err); if (ret == false) { return false; } return true; } }; //其中的成员变量有sockfd用来存放TCP套接字 //还有消息类型msg_type用来表示消息是什么类型的 //user_id_用来存放用户的id //repl_satus恢复状态 class ChatMsg{ public: ChatMsg(){ sockfd_ = -1; msg_type_ = -1; user_id_ = -1; reply_status_ = -1; json_msg_.clear(); } ~ChatMsg(){} /* * 提供反序列化的接口, 接收完毕请求之后进行反序列化 * 这里的反序列化接口提供参数sockfd需要我们在处理各种业务的时候需要从消息队列中获取 * */ int PraseChatMsg(int sockfd, const std::string& msg){ //1.调用jsoncpp的反序列化接口 Json::Value tmp; bool ret = JsonUtil::UnSerialize(msg, &tmp); if (ret == false){ return -1; } //2.赋值给成员变量 sockfd_ = sockfd; msg_type_ = tmp["msg_type"].asInt(); user_id_ = tmp["user_id"].asInt(); reply_status_ = tmp["reply_status"].asInt(); json_msg_ = tmp["json_msg"]; return 0; } /* * 提供序列化的接口 - 回复应答的时候使用 * msg : 出参, 用于获取序列化完毕的字符串 * 序列化就是我们将要发送的对象转化为json串之后通过json中的序列化函数将我们转化的json串转化为string * */ bool GetMsg(std::string* msg){ Json::Value tmp; tmp["msg_type"] = msg_type_; tmp["user_id"] = user_id_; tmp["reply_status"] = reply_status_; tmp["json_msg"] = json_msg_; return JsonUtil::Serialize(tmp, msg); } /* * 获取json_msg_当中的value值 * */ std::string GetValue(const std::string& key){ if (!json_msg_.isMember(key)){ return ""; } return json_msg_[key].asString(); } /* * 设置json_msg_当中的kv键值对 * */ void SetValue(const std::string& key, const std::string& value){ json_msg_[key] = value; } void SetValue(const std::string& key, int value){ json_msg_[key] = value; } void Clear(){ msg_type_ = -1; user_id_ = -1; reply_status_ = -1; json_msg_.clear(); } public: //存放的客户端文件名描述符, 方便发送线程, 通过该字段将数据发送给对应的客户端 int sockfd_; //消息类型 #if 0 enum chat_msg_type{ Register = 0, //0, 注册请求 Register_Resp, //1, 注册应答 Login, //2. 登录请求 Login_Resp, //3, 登录应答 AddFriend, //4, 添加好友请求 AddFriend_Resp, //5, 添加好友请求应答 SendMsg, //6, 发送消息 SendMsg_Resp, //7, 发送消息应答 PushMsg, //8, 推送消息 PushMsg_Resp, //9, 推送消息应答 PushAddFriendMsg, //10, 推送添加好友请求 PushAddFriendMsg_Resp, //11, 推送添加好友请求的应答 GetFriendMsg, //12, 获取全部好友信息 GetFriendMsg_Resp //13, 获取全部好友信息应答 }; #endif int msg_type_; //用户id int user_id_; //应答的状态 #if 0 enum reply_status{ REGISTER_SUCCESS = 0, //0, 注册成功 REGISTER_FAILED, //1,注册失败 LOGIN_SUCESSS, //2, 登录成功 LOGIN_FAILED, //3, 登陆失败 ADDFRIEND_SUCCESS, //4, 添加好友成功 ADDFRIEND_FAILED, //5, 添加好友失败 SENDMSG_SUCCESS, //6, 发送消息成功 SENDMSG_FAILED, //7, 发送给消息失败 GETFRIEND_SUCCESS, //8,获取好友列表成功 GETFRIEND_FAILED //9, 获取好友列表失败 }; #endif int reply_status_; /* * Json消息 * json消息的内容会随着消息类型的不同, 字段不一样 * */ Json::Value json_msg_; };
-
3.4 服务端
- 服务端大致就是分为epoll监视线程,发送线程,工作线程,缓存线程。
- 1.epoll监视线程当获取就绪事件的时候就将其存放到缓存队列中:
static void* epoll_wait_start(void* arg){ pthread_detach(pthread_self()); ChatServer* cs = (ChatServer*)arg; while (EXIT_SIGNALLL){ struct epoll_event arr[10]; int ret = epoll_wait(cs->epoll_fd_, arr, sizeof(arr) / sizeof(arr[0]), -1); if (ret < 0){ continue; } //正常获取了就绪的事件结构, 一定全部都是新连接套接字 for (int i = 0; i < ret; i++){ char buf[TCP_DATA_MAX_LEN] = { 0 }; //隐藏的问题: TCP粘包 ssize_t recv_size = recv(arr[i].data.fd, buf, sizeof(buf)-1, 0); if (recv_size < 0){ //接收失败了 std::cout << "recv failed : sockfd is " << arr[i].data.fd << std::endl; continue; } else if (recv_size == 0){ //对端关闭连接了 epoll_ctl(cs->epoll_fd_, EPOLL_CTL_DEL, arr[i].data.fd, NULL); close(arr[i].data.fd); cs->user_mana_->SetUserOffLine(arr[i].data.fd);//将用户状态改为下线OFFLINE continue; } printf("epoll_wait_start recv msg : %s from sockfd is %d\n", buf, arr[i].data.fd); //正常接收回来了, 将接收回来的数据放到接收线程的队列当中, 等到工作线程从队列当中获取消息, 进而进行处理 //3.将接收到的数据放到接收队列当当中 std::string msg; msg.assign(buf, strlen(buf)); ChatMsg cm; cm.PraseChatMsg(arr[i].data.fd, msg); cs->recv_que_->Push(cm); } } return NULL; }
- 发送线程:就是从发送队列中无脑取消息然后发送即可
static void* send_msg_start(void* arg){ pthread_detach(pthread_self()); ChatServer* cs = (ChatServer*)arg; while (EXIT_SIGNALLL){ //1.从队列拿出数据 ChatMsg cm; cs->send_que_->Pop(&cm); std::string msg; cm.GetMsg(&msg); std::cout << "send thread: " << msg << std::endl; //2.发送数据 send(cm.sockfd_, msg.c_str(), msg.size(), 0); } return NULL; }
- 工作线程:对于客户端发送来的不同请求进行处理
static void* deal_start(void* arg){ pthread_detach(pthread_self()); ChatServer* cs = (ChatServer*)arg; while (EXIT_SIGNALLL){ //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 AddFriend:{ cs->DealAddFriend(cm); break; } case PushAddFriendMsg_Resp:{ cs->DealAddFriendResp(cm); break; } case SendMsg: { cs->DealSendMsg(cm); break; } case GetFriendMsg:{ cs->GetAllFriendInfo(cm); break; } default:{ break; } } //3. 组织应答 } return NULL; }
- 缓存线程:当客户端不在线的时候我们就可以将消息放入缓存队列中然后缓存线程来对其循环检测如果用户上线那么就更新套接字向其发送消息
//缓存线程:当服务端程序跑开时我们就将数据库中存储的要发送的消息都加载到缓存队列中,然后我们的缓存线程就择机发送缓存队列中的消息 //这里会不会存在一个问题:当我们将消息从队列中拿出来但是这条消息要发送的客户端没有在线,但是这里我们还没来得及将消息重新放入缓存队列但是此时线程已经结束。导致我们无法向数据库中保存当前消息。???????????????? static void* cache_start(void* arg) { pthread_detach(pthread_self()); ChatServer* cs = (ChatServer*)arg; while(EXIT_SIGNALLL) { if(cs->buf_que_->Empty()) { sleep(1); continue; } ChatMsg cm; cs->buf_que_->Pop(&cm); int user_id=-1; if(cm.msg_type_==PushAddFriendMsg) { //如果是PushAddfriendMsg类型消息那么我们这里还需要获取要发送的用户的id,从而获取它的套接字。 UserInfo be_add_ui; cs->user_mana_->IsLogin(cm.GetValue("telnum"), &be_add_ui); user_id=be_add_ui.userid_; } else if(cm.msg_type_==AddFriend_Resp) { user_id=atoi(cm.GetValue("userid").c_str()); } else { user_id=cm.user_id_; } if(cs->user_mana_->IsLogin(user_id)==ONLINE) {//如果当前用户在线:更新套接字,放入发送队列发送消息//这里应该采用map的方式将一个用户id的消息按照id从前往后维护起来,发送的时候按照时间顺序发送 sleep(1); int socket_fd=cs->user_mana_->getsockefd(user_id); if(socket_fd<0) {//如果返回值为-1那么就丢弃这条消息,因为用户中没有这个人 continue; } cm.sockfd_=socket_fd; cs->send_que_->Push(cm); } else {//如果不在线:那么就将其在添加到缓存队列中 cs->buf_que_->Push(cm); } } return NULL; }
- 处理进程退出数据持久化,将数据存入数据库中。
void dealexit() { //处理线程退出的时候情况: //1.让所有的工作线程完成自己的任务 EXIT_SIGNALLL=0;//将退出信号置为0那么此时所有的工作线程都在完成自己的工作之后就退出 //将缓存队列中的消息都放到数据库中: if(user_mana_->put_cachemsg(*buf_que_)==false) { std::cout<<"存入数据库失败"<<std::endl;//这句话用于调试 } exit(1); }
3.5客户端:
客户端这里我们用到了MFC编程:关于MFC在b站上的黑马程序员讲解的特别容易理解,这里就不多赘述了,可以去学习一下。这里给出客户端消息流转图:
需要创建的窗口资源
这里客户端看似复杂一点但是其实是很简单的,这里已经给了各个界面的布局和每个界面的功能,那么我们只要创建好相应的界面然后给每个界面的所有功能按键实现相应的代码,那么就可以完成我们的客户端。这里客户端向服务端组织消息的时候只要想着我们服务端完成的程序内容,按照其所需要的信息然后组织发送即可。一切都是水到渠成。还有服务端一定要从最低端写起也就是数据库管理模块到用户管理模块再到服务端管理模块,只要按照流程图细细分析一定可以写出。
当然这里也附上整个项目的源码:
码云地址:聊天室 · 月半木斤/项目 - 码云 - 开源中国 (gitee.com)