注册界面:
数据库插入数据:
登录界面:
登录成功跳转到聊天界面:
同样的有显示未读消息数的功能
添加好友:
数据库表的创建:
在此创建两张表: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;
}
用户管理模块首先进行数据库的初始化,主要实现对数据库的连接和设置数据库属性:
//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_ <
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;
}
我们在服务端有三个消息队列,一个消息队列用于接收就绪的文件描述符,一个队列放接收到的线程,还有一个队列放要发送的线程,用STL中的队列来实现,由于queue本身线程不安全,我们将队列做了一个封装,进行加锁保护,保证线程安全。
由于不同的队列存放的对象类型不同,我们在此用模板,实现一个模板类。
这个线程安全的消息队列不支持按类型区分,如果想按类型区分,可以使用Message Queue,但是为什么不用它呢:主要是因为我服务端三个队列不需要按照类型去进行区分,只要满足先进先出就行,简单说就是用不上。
代码如下:
#pragma once
#include
#include
//线程安全的消息队列
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_的容量
};
为什么要有一个单独的接收线程呢?为什么不让这个线程接收到请求后直接处理这些业务请求呢?因为假如某个业务十分耗时,那就需要占用这个接收线程很久的时间去处理该业务,此时如果有其他文件描述符就绪的话,就没有线程去接收它们,所以我们需要一个单独的接收线程去接收就绪的文件描述符。
接收线程:接收客户端发来的数据
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、recv
for (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、主线程进行accept
while (1) {
int new_sockfd = accept(sockfd_, NULL, NULL);
if (new_sockfd < 0) {
continue;
}
//添加到epoll
struct epoll_event ee;
ee.events = EPOLLIN;
ee.data.fd = new_sockfd;
epoll_ctl(ep_fd_, EPOLL_CTL_ADD, new_sockfd, &ee);
}
}
我们为什么不直接使用Json呢?为什么要对Json进行一层封装呢?因为我们在服务端和客户端之间传递的消息不仅仅是用户发送的消息,还包括了用户请求的类型,消息的类型,所以说直接用Json作为传输的数据格式不能满足我们描述性的内容,因此,我们进行了一个简单的消息类型的封装。
在传输过程中,增加一些描述性的内容:
首先是套接字描述符,它的作用只存在在服务端,消息在服务端各个模块之间流转的时候,一直携带着服务端为某一个客户端创建的新连接套接字的值。这个值存在的意义:在发送线程往客户端发送应答的时候,发送线程就能清楚消息该发往哪个客户端。
其次是消息类型,用来描述当前消息的类型。
接着是响应状态,描述某条请求的响应状态。
客户端发送的数据,经过序列化后往服务端发送,服务端接收到后,进行反序列化后得到ChatMsg对象。
我们设置clean函数,是因为服务端收到客户端发来的ChatMsg之后,会根据请求进行应答,之后ChatMsg里面的值会发生变化,我们不需要重新申请一个ChatMsg的空间,可以直接将原ChatMsg清空,再设置新的ChatMsg的值,为什么要全部清空呢?为了防止我们修改时出错,在修改某些属性时出错。
void clear() {
msg_type_ = -1;
reply_status_ = -1;
user_id_ = -1;
json_msg_.clear();
}
我们数据在传输的时候,要进行序列化,为什么要进行序列化呢?
总之,通过序列化数据,可以将其转换为适合传输、存储和处理的格式,以实现跨系统、跨平台和跨语言的数据交流和操作。
在此封装了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, //登录应答 3
AddFriend, //添加好友请求 4
AddFriend_Resp,//添加好友应答 5
PushAddFriendMsg, //推送添加好友请求 6
PushAddFriendMsg_resp, //推送添加好友应答 7
SendMsg, //发送数据 8
SendMsg_Resp, //发送数据应答 9
PushMsg, //推送数据 10
PushMsg_Resp, //推送数据应答
GetFriend, //获取好友信息的请求
GetFriend_Resp//获取好友信息的应答 13
};
//响应类型
enum ReplyStatus {
REGISTER_SUCCESS = 0,//注册成功0
REGISTER_FAILED, //注册失败1
LOGIN_SUCCESS, //登录成功 2
LOGIN_FAILED, //登录失败3
ADDFRIEND_SUCCESS, //添加好友成功4
ADDFRIEND_FAILED, //添加好友失败5
SENDMSG_SUCCESS, //发送消息成功6
SENDMSG_FAILED, //发送消息失败 7
GETFRIEND_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_;
};
客户端是用MFC实现的,只简单的用到一些基本的控件,B站上有黑马程序员的MFC讲解,有兴趣可以看看。
消息队列:不同于之前的队列,这个消息队列支持按照类型去存放,也支持按照消息类型去获取。也就是说可以按照消息类型先进先出。所以我们客户端只有一个消息队列就可以满足需求,在push和pop的时候,一定要告诉消息队列,想push和pop的是什么类型的消息。
按类型存放消息的主要设计思路就是用数组来存放队列,不同下标的队列代表不同的消息类型。vector
消息流转图:
客户端的代码很多都是MFC自己生成的代码,MFC可以帮助我们生成类和变量,使用的时候非常方便,我们在原有基础上添加上我们需要的业务代码即可。
码云地址:聊翻天·林深方见鹿/项目 - 码云 - 开源中国(gitee.com)