项目地址:https://gitee.com/cai-jinxiang/chat-server
网络模块:采用muduo库完成,解耦了网络与业务模块
服务层:使用c++11技术,设计了消息id及回调函数的绑定,服务器和客户端
数据存储层:使用mysql存储消息,用户信息,离线消息,群聊消息等
负载均衡模块:Nginx的基于TCP的负载均衡模块,长连接
redis的发布订阅功能,作为消息队列,服务器中不同用户的通信。
使用的技术
数据库连接池
- MySQL数据库编程
- 懒汉式单例模式
- 智能指针,自定义删除器(lambda表达式)
- 基于CAS的原子整形
通过queue队列管理数据库的连接,当系统需要访问数据库时,向池中申请连接,对数据库进行操作
开启一个线程负责创建连接(连接队列空时)
一个线程负责销毁连接(空闲连接过多时)
Json
一种轻量级的数据交换格式,只包含一个json.hpp
使用Json格式作为客户端和服务端之间的消息传递格式
- 数据包装为一个Json对象,
- Json.dump()将Json对象序列化为一个string格式,传输给对方
- 对方接受到string格式,json::parse(recvBuf),将recvBuf反序列化为一个Json对象
muduo网络库
- Tcp网络编程库,支持Reactor模型
- 只需要简单组合好连接回调函数和消息回调函数,就可以提高一个高效的网络服务
CMake
一个项目通常的目录
bin:生成的可执行文件
lib:生成的库文件
include:头文件
src:源文件
build:编译产生的中间文件
example:示例文件
thridparty:第三方库的源码文件,比如json.hpp
CMakeLists.txt
autobuild.sh :一键编译,执行的cmake文件
在做项目时,在项目根目录创建 build文件,把cmake文件放进去,并在cmake设置文件中,设置可执行文件的路径为根目录下的bin
nginx
- 配置tcp负载均衡
- 多个服务器都被nginx代理
- 所有的用户都连接一个nginx的端口,nginx负责均衡的分发给不同的服务器
redis的发布订阅功能
使用redis的发布订阅功能,在多服务器中进行通信
通过hredis库进行c++的redis编码
- 服务端连接成功redis后,开启线程监听redis发送的消息
// 在独立线程中接收订阅通道中的消息
void Redis::observer_channel_message()
{
redisReply *reply = nullptr;
while (REDIS_OK == redisGetReply(this->_subcribe_context, (void **)&reply))
{
// 订阅收到的消息是一个带三元素的数组
if (reply != nullptr && reply->element[2] != nullptr && reply->element[2]->str != nullptr)
{
// 给业务层上报通道上发生的消息
_notify_message_handler(atoi(reply->element[1]->str) , reply->element[2]->str);
}
freeReplyObject(reply);
}
cerr << ">>>>>>>>>>>>> observer_channel_message quit <<<<<<<<<<<<<" << endl;
}
- 登录成功后,服务器会向redis服务 订阅指定的消息,用户id的消息.
redisAppendCommand将消息存入缓存
redisBufferWrite 将缓存消息发送redis
不直接使用redisCommand是因为,redisCommand会阻塞线程等待redis的回应,
// 向redis指定的通道subscribe订阅消息
bool Redis::subscribe(int channel)
{
// SUBSCRIBE命令本身会造成线程阻塞等待通道里面发生消息,这里只做订阅通道,不接收通道消息
// 通道消息的接收专门在observer_channel_message函数中的独立线程中进行
// 只负责发送命令,不阻塞接收redis server响应消息,否则和notifyMsg线程抢占响应资源
if (REDIS_ERR == redisAppendCommand(this->_subcribe_context, "SUBSCRIBE %d", channel))
{
cerr << "subscribe command failed!" << endl;
return false;
}
// redisBufferWrite可以循环发送缓冲区,直到缓冲区数据发送完毕(done被置为1)
int done = 0;
while (!done)
{
if (REDIS_ERR == redisBufferWrite(this->_subcribe_context, &done))
{
cerr << "subscribe command failed!" << endl;
return false;
}
}
// redisGetReply
return true;
}
- 当本服务器的用户发送消息给在线,但是不在本服务器的用户时,发送给redis服务器
// 向redis指定的通道channel发布消息
bool Redis::publish(int channel, string message)
{
redisReply *reply = (redisReply *)redisCommand(_publish_context, "PUBLISH %d %s", channel, message.c_str());
if (nullptr == reply)
{
cerr << "publish command failed!" << endl;
return false;
}
freeReplyObject(reply);
return true;
}
- 用户下线之后取消订阅
/ 向redis指定的通道unsubscribe取消订阅消息
bool Redis::unsubscribe(int channel)
{
if (REDIS_ERR == redisAppendCommand(this->_subcribe_context, "UNSUBSCRIBE %d", channel))
{
cerr << "unsubscribe command failed!" << endl;
return false;
}
// redisBufferWrite可以循环发送缓冲区,直到缓冲区数据发送完毕(done被置为1)
int done = 0;
while (!done)
{
if (REDIS_ERR == redisBufferWrite(this->_subcribe_context, &done))
{
cerr << "unsubscribe command failed!" << endl;
return false;
}
}
return true;
}
优化方向
消息加密
- 使用非对称加密 加密传输 对称加密密钥
保证消息按序显示
- 本服务器只在局域网上测试,网络状况良好,一般不会出现消息到达顺序与发送顺序不一致,但在真实网络情况下,是可能出现的
- 解决方案:
- 通过给消息加上一个循环使用的序列号,类似tcp的seq。
- 加上这个seq后还可以实现撤回消息的功能
- 加时间戳是无法保证消息顺序到达的,比如到达了一个时分秒为15:14:02的消息,此时也许有个15:14:01的消息还未到达,但是客户端显然不会知道,也就无法保证消息按序显示
chatServer如何感知用户掉线
-
目前来说只有client主动下线
-
添加心跳机制,使用UDP协议绑定一个端口,负责心跳,让在线用户每隔1s发送一个心跳包,当超过ns没有心跳包时,可以认为用户下线。
-
tcp有个保活机制,
- 但是tcp在传输层,用户掉线在应用层会有很多的资源需要释放。
- 当用户的业务出现问题时,对于服务器来说相当于下线,但是如果使用tcp的保活机制,会认为用户仍然在线,因此使用tcp保活机制不合适。
确保消息发送成功(消息发送不成功,用户能知道)
问题:在应用层中,只是通过send(fd, buf, sizeof(buf), 0)的返回值,来判断是否发送成功,但是,send的成功只是将buf的内容拷贝到内核的TCP缓冲区,TCP虽然有超时重传机制,但是如果一直接收不到对方的ACK超过一定次数后,就不会再传了。
解决方案:
- 在应用层中实现类似TCP的功能,发送的消息存储下来加入seq字段,接受到消息后发送ack确认,建立一个应用层的超时重传机制。可以与心跳机制建立交互,例如当心跳失败时直接发送失败。
历史消息存储
- 本地存储:文件夹、SQLite
- 云存储