文章目录
一、服务器网络模块ChatServer
1.1 网络模块基本框架搭建
服务器网络模块: 网络模块我们直接利用优秀的muduo开源库即可,完全基于Reactor模型,其底层实质上为epoll + pthread线程池实现的,使用其好处为我们能够将网络I/O的代码和业务代码分开,我们可以利用现成的框架快速高效的进行项目开发。
muduo网络库的编程流程为:
1、组合TcpServer对象;
2、创建EventLoop事件循环对象的指针,可以向loop上注册感兴趣的事件,相应事件发生loop会上报给我们;
3、明确TcpServer构造函数需要的参数,输出服务器类ChatServer的构造函数;
TcpServer(EventLoop* loop, //事件循环
const InetAddress& listenAddr, //绑定IP地址 + 端口号
const string& nameArg, //TcpServer服务器名字
Option option = kNoReusePort); //tcp协议选项
4、在当前服务器类的构造函数中,注册业务代码中处理连接断开的回调函数和处理读写事件的回调函数,主要通过下面两个函数回调实现;
void setConnectionCallback(const ConnectionCallback& cb) //链接的创建与断开
{ connectionCallback_ = cb; }
void setMessageCallback(const MessageCallback& cb) //消息读写事件
{ messageCallback_ = cb; }
5、设置合适的服务器端线程数量,muduo会自动分配I/O线程与工作线程;
6、启动服务,开启事件循环;
网络模块主要代码如下所示:muduo库框架代码
chatserver.hpp:
#ifndef CHATSERVER_H
#define CHATSERVER_H
#include <muduo/net/TcpServer.h>
#include <muduo/net/EventLoop.h>
using namespace muduo;
using namespace muduo::net;
//聊天服务器类
class ChatServer
{
public:
//初始化聊天服务器对象
ChatServer(EventLoop *loop, const InetAddress& listenAddr, const string& nameArg);
//启动服务
void start();
private:
//上报连接相关信息的回调函数:参数为连接信息
void onConnection(const TcpConnectionPtr&);
//上报读写事件相关信息的回调函数:参数分别为连接、缓冲区、接收到数据的事件信息
void onMessage(const TcpConnectionPtr&, Buffer *, Timestamp);
TcpServer _server; //组合muduo库,实现服务器功能的类对象
EventLoop *_loop; //创建EventLoop事件循环对象的指针
};
#endif
chatserver.cpp:
#include "chatserver.hpp"
#include <functional>
using namespace std;
using namespace placeholders;
//3、初始化聊天服务器对象
ChatServer::ChatServer(EventLoop *loop, const InetAddress& listenAddr, const string& nameArg)
:_server(loop, listenAddr, nameArg)
,_loop(loop)
{
//注册用户连接的创建和断开事件的回调
_server.setConnectionCallback(std::bind(&ChatServer::onConnection, this, _1));
//注册用户读写事件的回调
_server.setMessageCallback(std::bind(&ChatServer::onMessage, this, _1, _2, _3));
//设置服务器线程数量 1个I/O线程,3个工作线程
_server.setThreadNum(4);
}
//启动服务,开启事件循环
void ChatServer::start()
{
_server.start();
}
//上报连接相关信息的回调函数:参数为连接信息
void ChatServer::onConnection(const TcpConnectionPtr&)
{
}
//上报读写事件相关信息的回调函数:参数分别为连接、缓冲区、接收到数据的事件信息
void ChatServer::onMessage(const TcpConnectionPtr&, Buffer *, Timestamp)
{
}
代码实现完成后我们写一个main函数测试一下,再利用cmake进行项目构建,最终在bin目录下成功生成可执行文件ChatServer。
main.cpp:
#include "chatserver.hpp"
#include <iostream>
using namespace std;
int main()
{
EventLoop loop;
InetAddress addr("127.0.0.1", 6000);
ChatServer server(&loop, addr, "ChatServer");
server.start(); //服务器启动,开启事件循环
loop.loop();
return 0;
}
1.2 网络模块与业务模块代码解耦
网络模块与业务模块代码解耦:
假设此时有一个用户在进行登录业务,服务端收到json数据,我们将数据接收解析后,数据中包含msgid来区分是什么业务,此时常规操作是用if…else…或switch…case…来依次判断msgid调用业务对应方法,一旦新增业务或删减业务时,网络模块代码还需要进行相应改动,这样网络模块代码与业务模块代码耦合性太高,虽然能实现预期功能,但是并没有达到一个很好的设计。
我们实现一个服务器业务类,主要关注业务的处理,有一个实例对象即可处理业务,因此设计为单例模式。将网络模块与业务模块进行解耦,业务什么时候发生与业务发生后如何处理是分离开来的,我们可以通过json解析出来的msgid,事先为其绑定好回调操作,每一个msgid对应一个回调操作(可以利用map表来处理映射关系),这样网络模块解析到msgid即可自动获取一个业务处理器handler(网络模块无法看到,业务模块提前绑定好的),自动处理相应业务。
chatserver.cpp中业务模块实现:
//上报连接相关信息的回调函数:参数为连接信息
void ChatServer::onConnection(const TcpConnectionPtr &conn)
{
if (!conn->connected()) //客户端断开连接,释放连接资源 moduo库会打印相应日志
{
conn->shutdown();
}
}
//网络模块与业务模块解耦:不直接调用相应方法,业务发生变化此处代码也不需要改动
//上报读写事件相关信息的回调函数:参数分别为连接、缓冲区、接收到数据的事件信息
void ChatServer::onMessage(const TcpConnectionPtr &conn, Buffer *buffer, Timestamp time)
{
string buf = buffer->retrieveAllAsString(); //将buffer缓冲区收到的数据存入字符串
json js = json::parse(buf); //数据反序列化
//完全解耦网络模块与业务模块代码:通过js读出的js["msgid"] =》 获取业务处理器handler =》 conn js time
auto msgHandler = ChatService::instance()->getHandler(js["msgid"].get<int>()); //获取msgid相应的事件处理器
msgHandler(conn, js, time); //回调消息绑定好的事件处理器,执行业务处理
}
public.hpp:服务器与客户端公共代码
#ifndef PUBLIC_H
#define PUBLIC_H
//server和client公共文件
//消息类型
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
REG_MSG //注册消息
};
#endif
chatservice.hpp服务器业务模块头文件:
#ifndef CHATSERVICE_H
#define CHARSERVICE_H
#include "json.hpp"
#include <unordered_map>
#include <functional>
#include <muduo/net/TcpConnection.h>
using namespace std;
using namespace muduo;
using namespace muduo::net;
using json = nlohmann::json;
//处理消息事件回调方法类型
using MsgHandler = std::function<void(const TcpConnectionPtr &conn, json &js, Timestamp)>;
//聊天服务器业务类,设计为单例模式:给msgid映射事件回调(一个消息id映射一个事件处理)
class ChatService
{
public:
//获取单例对象的接口函数
static ChatService* instance();
//获取消息msgid对应的处理器
MsgHandler getHandler(int msgid);
//处理登录业务
void login(const TcpConnectionPtr &conn, json &js, Timestamp time);
//处理注册业务
void reg(const TcpConnectionPtr &conn, json &js, Timestamp time);
private:
ChatService();
unordered_map<int, MsgHandler> _msgHandlerMap; //消息处理器map表 每一个msgid对应一个业务处理方法
};
#endif
chatservice.cpp服务器业务模块代码:此处具体业务还未实现
#include "chatservice.hpp"
#include "public.hpp"
#include <muduo/base/Logging.h>
using namespace muduo;
//获取单例对象的接口函数
ChatService* ChatService::instance()
{
static ChatService service;
return &service;
}
//构造函数:注册消息以及对应的回调操作 实现网络模块与业务模块解耦的核心
ChatService::ChatService()
{
_msgHandlerMap.insert({LOGIN_MSG, std::bind(&ChatService::login, this, _1, _2, _3)});
_msgHandlerMap.insert({REG_MSG, std::bind(&ChatService::reg, this, _1, _2, _3)});
}
//获取消息msgid对应的处理器
MsgHandler ChatService::getHandler(int msgid)
{
auto it = _msgHandlerMap.find(msgid);
if (it == _msgHandlerMap.end())
{
return [=](const TcpConnectionPtr &conn, json &js, Timestamp) {
LOG_ERROR << "msgid:" << msgid << " can not find handler!";
}; //msgid没有对应处理器,打印日志,返回一个默认处理器,空操作
}
else
{
return _msgHandlerMap[msgid];
}
}
//处理登录业务
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
LOG_INFO << "login service!"; //测试代码
}
//处理注册业务
void ChatService::reg(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
LOG_INFO << "reg service!"; //测试代码
}
代码完成后我们利用CMake工具进行构建,构建完成生成可执行文件ChatServer。利用telnet命令发送消息进行测试,如图所示可以正常进行数据处理,说明网络模块通过解析json消息id,派发相应的事件回调到业务模块是正常的。
二、服务器数据模块
服务器数据模块: 将数据库数据与业务模块代码区分开来,符合ORM(对象关系映射)框架设计,业务层操作的都是数据层的对象,数据层封装数据库SQL相应的操作。
2.1 数据库操作代码封装
数据库模块的开发必须确保我们系统上存在mysql.h和libmysqlclient.so库,我们需要用到其中的方法,对数据库进行操作。
上述库存在后,我们可以进行数据库代码的封装了,以下为数据库操作常用的一些方法:
1、 初始化连接环境:MySQL *mysql_init(MYSQL *mysql);
MySQL *mysql_init(MYSQL *mysql); //用来分配或者初始化一个MYSQL对象,用于连接mysql服务端。如果你传入的参数是NULL指针,它将自动为你分配一个MYSQL对象,如果这个MYSQL对象是它自动分配的,那么在调用mysql_close的时候,会释放这个对象。
2、关闭连接环境:void mysql_close(MYSQL *mysql);
void mysql_close(MYSQL *mysql); //关闭先前打开的连接。 如果处理程序是由mysql_init()或mysql_connect()自动分配的,则mysql_close()还会释放 mysql 指向的连接处理程序。不要在处理程序关闭后使用它。
3、建立到主机上运行的MySQL服务器的连接:MYSQL *mysql_real_connect();
MYSQL *mysql_real_connect(MYSQL *mysql, const char *host, const char *user, const char *passwd, const char *db, unsigned int port, const char *unix_socket, unsigned long client_flag)
//尝试与运行在主机上的MySQL数据库引擎建立连接。在你能够执行需要有效MySQL连接句柄结构的任何其他API函数之前,mysql_real_connect()必须成功完成。
//mysql:第1个参数应是已有MYSQL结构的地址。
//host:主机名或IP地址
//user:用户的MySQL登录ID
//passwd:用户的密码
//db:数据库名称
//port:端口号
//unix_socket:应使用的套接字或命名管道
//client_flag:通常为0
4、查询数据:mysql_query(query,connection);
mysql_query(query,connection);
//query:必需。规定要发送的 SQL 查询。注释:查询字符串不应以分号结束。
//connection:可选。规定 SQL 连接标识符。如果未规定,则使用上一个打开的连接。
5、将初始化结果集检索:MYSQL_RES *mysql_use_result(MYSQL *mysql);
MYSQL_RES *mysql_use_result(MYSQL *mysql);
//对于成功检索数据的每个查询(SELECT、SHOW、DESCRIBE、EXPLAIN),必须调用mysql_store_result()或mysql_use_result()。mysql_use_result()将初始化结果集检索,但并不像mysql_store_result()那样将结果集实际读取到客户端。它必须通过对mysql_fetch_row()的调用,对每一行分别进行检索。这将直接从服务器读取结果,而不会将其保存在临时表或本地缓冲区内,与mysql_store_result()相比,速度更快而且使用的内存也更少。客户端仅为当前行和通信缓冲区分配内存,分配的内存可增加到max_allowed_packet字节。
db.h数据库操作头文件:
#ifndef DB_H
#define DB_H
#include <mysql/mysql.h>
#include <string>
using namespace std;
//数据库操作类型
class MySQL
{
public:
//初始化连接:开辟存储连接的资源空间
MySQL();
//释放连接:释放存储连接的资源空间
~MySQL();
//连接数据库
bool connect();
//更新操作
bool update(string sql);
//查询操作
MYSQL_RES* query(string sql);
//获取连接
MYSQL* getConnection();
private:
MYSQL *_conn; //与MySQL Server的一条连接
};
#endif
db.cpp数据库操作代码:
#include "db.h"
#include <muduo/base/Logging.h>
//数据库配置信息
static string server = "127.0.0.1";
static string user = "root";
static string password = "admin123";
static string dbname = "chat";
// 初始化连接:开辟存储连接的资源空间
MySQL::MySQL()
{
_conn = mysql_init(nullptr);
}
// 释放连接:释放存储连接的资源空间
MySQL::~MySQL()
{
if (_conn != nullptr)
{
mysql_close(_conn);
}
}
// 连接数据库
bool MySQL::connect()
{
MYSQL *p = mysql_real_connect(_conn, server.c_str(), user.c_str(),
password.c_str(), dbname.c_str(), 3306, nullptr, 0);
if (p != nullptr)
{
mysql_query(_conn, "set names gbk"); // C/C++默认编码为ASCII,需要设置一下让其支持中文,否则msql拉下来的中文为乱码
LOG_INFO << "connect mysql success!";
}
else
{
LOG_INFO << "conncet mysql fail!";
}
return p;
}
// 更新操作
bool MySQL::update(string sql)
{
if (mysql_query(_conn, sql.c_str()))
{
LOG_INFO << __FILE__ << ":" << __LINE__ << ":"
<< sql << "更新失败!";
return false;
}
return true;
}
// 查询操作
MYSQL_RES *MySQL::query(string sql)
{
if (mysql_query(_conn, sql.c_str()))
{
LOG_INFO << __FILE__ << ":" << __LINE__ << ":"
<< sql << "查询失败!";
return nullptr;
}
return mysql_use_result(_conn);
}
//获取连接
MYSQL* MySQL::getConnection()
{
return _conn;
}
2.2 数据层代码框架设计
数据库操作与业务代码进行分离,业务代码处理的都为对象,数据库层操作具体SQL语句,因此我们定义相应的类,每一个类对应数据库中一张表,将数据库读出来的字段提交给业务使用。
1、表与类的映射user.hpp:例如我们之前创建的User表,为其专门实现一个映射类User,对外提供公有接口访问私有成员变量,此处方法比较简单我们直接在user.hpp实现了。
#ifndef USER_H
#define USER_H
#include <iostream>
using namespace std;
//User用户表的映射类:映射表的相应字段
class User
{
public:
//构造函数
User(int i = -1, string n = "", string pwd = "", string st = "offline")
{
this->id = i;
this->name = n;
this->password = pwd;
this->state = st;
}
//设置相应字段
void setId(int i){this->id = i;}
void setName(string n) {this->name = n;}
void setPwd(string pwd) {this->password = pwd;}
void setState(string st) {this->state = st;}
//获取相应字段
int getId() {return this->id;}
string getName() {return this->name;}
string getPwd() {return this->password;}
string getState() {return this->state;}
private:
int id; //用户id
string name; //用户名
string password; //用户密码
string state; //当前登录状态
};
2、User表的数据操作类usermodel.hpp:针对表数据的增删改查。
#ifndef USERMODEL_H
#define USERMODEL_H
#include "user.hpp"
//User用户表的数据操作类:针对表的增删改查
class UserModel
{
public:
//增加新用户
bool insert(User &user);
private:
};
#endif
相应方法的实现usermodel.cpp:
#include "usermodel.hpp"
#include "db.h"
#include <iostream>
using namespace std;
//增加新用户
bool UserModel::insert(User &user)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "insert into user(name, password, state) values('%s', '%s', '%s')",
user.getName().c_str(), user.getPwd().c_str(), user.getState().c_str());
//2、发送SQL语句,进行处理
MySQL mysql;
if (mysql.connect())
{
if (mysql.update(sql))
{
//id为自增键,设置回去user对象添加新生成的用户id
user.setId(mysql_insert_id(mysql.getConnection()));
return true;
}
}
return false;
}
三、服务器业务模块ChatService
服务器业务模块: 客户端发送的业务数据,先到达服务器端网络模块,网络模块进行事件分发到业务模块相应的业务处理器,最终通过数据层访问底层数据模块。
3.1 用户注册业务
用户注册: 服务器将客户端收到的json反序列化后存储至数据库中,依据是否注册成功给客户端返回响应消息。
我们业务层与数据层分离,需要操作数据层数据对象即可,因此需要在ChatService类中实例化一个数据操作类对象进行业务开发。
UserModel _userModel; //数据操作类对象
需要在消息类型EnMsgType中增加一个注册响应消息,给客户端返回是否注册成功标识:
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
REG_MSG, //注册消息
REG_MSG_ACK //注册响应消息
};
服务器注册业务流程:
1、客户端注册的消息过来后,网络模块将json数据反序列化后上报到注册业务中,因为User表中id字段为自增的,state字段为默认的,因此注册业务只需要获取name与password字段即可。
2、实例化User表对应的对象user,将获取到的name与password设置进去,再向UserModel数据操作类对象进行新用户user的注册。
3、注册完成后,服务器返回相应json数据给客户端:若注册成功,返回注册响应消息REG_MSG_ACK、错误标识errno(0:成功,1:失败)、用户id等组装好的json数据;若注册失败,返回注册响应消息REG_MSG_ACK、错误标识。
注册业务核心代码如下:
/处理注册业务 需要处理name、password字段
void ChatService::reg(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取name、password字段
string name = js["name"];
string password = js["password"];
//2、创建User对象,进行注册
User user;
user.setName(name);
user.setPwd(password);
bool state = _userModel.insert(user);
if (state) //注册成功
{
json response;
response["msgid"] = REG_MSG_ACK; //注册响应消息
response["errno"] = 0; //错误标识 0:成功 1:失败
response["id"] = user.getId();
conn->send(response.dump());
}
else //注册失败
{
json response;
response["msgid"] = REG_MSG_ACK;
response["errno"] = 1;
conn->send(response.dump());
}
}
我们测试一下:发送注册消息json数据。
可以看到,响应消息注册成功,user表中成功新增用户zhang san。
3.2 用户登录业务
3.2.1 基础登录业务实现
用户登录: 服务器反序列化数据后,依据id、密码字段后判断账号是否正确,依据是否登录成功给客户端返回响应消息。
在进行用户登录业务处理前,我们需要提前处理好以下几点:
1、我们需要在消息类型EnMsgType中增加一个登录响应消息,给客户端返回是否登录成功标识:
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
LOGIN_MAG_ACK, //登录响应消息
REG_MSG, //注册消息
REG_MSG_ACK //注册响应消息
};
2、还需要处理数据层方法query,提供给业务层使用,依据用户id查询用户的信息。
//根据用户id查询用户信息
User UserModel::query(int id)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "select * from user where id = %d", id);
//2、发送SQL语句,进行处理
MySQL mysql;
if (mysql.connect())
{
MYSQL_RES *res = mysql.query(sql); //查询id对应的数据
if (res != nullptr) //查询成功
{
MYSQL_ROW row = mysql_fetch_row(res); //获取行数据
if (row != nullptr)
{
User user;
user.setId(atoi(row[0]));
user.setName(row[1]);
user.setPwd(row[2]);
user.setState(row[3]);
mysql_free_result(res); //释放res动态开辟的资源
return user; //返回user对应的信息
}
}
}
return User(); //未找到,返回默认的user对象
}
3、如果用户id、密码、登录状态等都没问题,进行用户登录,登录完成要及时进行状态更新,此时还需要数据层方法updateState及时更新用户状态。
//更新用户状态信息
bool UserModel::updateState(User user)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "update user set state = '%s' where id = %d", user.getState().c_str(), user.getId());
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
if (mysql.update(sql))
{
return true;
}
}
return false;
}
服务器登录业务流程:
1、服务器获取输入用户id、密码字段。
2、查询id对应的数据,判断用户id与密码是否正确,分为以下三种情况返回相应json数据给客户端:
①若用户名、密码正确且未重复登录,及时更新登录状态为在线,返回登录响应消息LOGIN_MSG_ACK、错误标识errno(0:成功,1:失败,2:重复登录)、用户id、用户名等信息;
②若用户名、密码正确但重复登录,返回登录响应消息、错误标识、错误提示信息;
③若用户不存在或密码错误,返回登录响应消息、错误标识、错误提示信息;
登录业务核心代码如下:
// 处理登录业务 需要处理id、password字段
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
// 1、获取id、password字段
int id = js["id"].get<int>();
string password = js["password"];
// 传入用户id,返回相应数据
User user = _userModel.query(id);
if (user.getId() == id && user.getPwd() == password) // 登录成功
{
if (user.getState() == "online") // 用户已登录,不允许重复登录
{
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 2; //重复登录
response["errmsg"] = "该账户已经登录,请重新输入新账号";
conn->send(response.dump());
}
else //用户未登录,此时登录成功
{
user.setState("online");
_userModel.updateState(user); //更新用户状态信息
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
response["name"] = user.getName();
conn->send(response.dump());
}
}
else // 该用户不存在或密码输入错误,登录失败
{
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 1;
response["errmsg"] = "用户不存在或密码错误";
conn->send(response.dump());
}
}
我们测试一下:发送正确登录json数据,可以看到能够正常返回成功响应消息。
id为1的用户登录状态也由offline变为online了。
再测试一下不存在的id,可以看到能够正常返回错误响应消息。
3.2.2 记录用户连接信息处理
用户连接信息处理: 假设此时用户1向用户2发送消息(源id、目的id、消息内容),此时服务器收到用户1的数据了,要主动向用户2推送该条消息,那么如何知道用户2是那条连接呢。因此我们需要专门处理下,用户一旦登录成功,就会建立一条连接,我们便要将该条连接存储下来,方便后续消息收发的处理。
我们可以在聊天服务器业务类ChatService类中添加一个成员变量:
unordered_map<int, TcpConnectionPtr> _userConnMap; //存储在线用户的通信连接map表
在用户登录成功时便将用户id与连接信息记录在一个map映射表里,方便后续查找与使用。
_userConnMap.insert({id, conn}); //登录成功记录用户连接信息
线程安全问题: 上述我们虽然建立了用户id与连接的映射,但是在多线程环境下,不同的用户可能会在不同的工作线程中调用同一个业务,可能同时有多个用户上线、下线操作,因此要保证map表的线程安全。
我们可以在聊天服务器业务类ChatService类中再添加一个成员变量,利用互斥锁来保证map表线程安全:
mutex _connMutex; //互斥锁,保证线程安全
在map表前加锁保证其线程安全。
{
lock_guard<mutex> lock(_connMutex);
_userConnMap.insert({id, conn}); // 登录成功记录用户连接信息
}
加上这些处理,登录业务的核心代码如下:
// 处理登录业务 需要处理id、password字段
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
// 1、获取id、password字段
int id = js["id"].get<int>();
string password = js["password"];
// 传入用户id,返回相应数据
User user = _userModel.query(id);
if (user.getId() == id && user.getPwd() == password) // 登录成功
{
if (user.getState() == "online") // 用户已登录,不允许重复登录
{
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 2; // 重复登录
response["errmsg"] = "该账户已经登录,请重新输入新账号";
conn->send(response.dump());
}
else // 用户未登录,此时登录成功
{
{
lock_guard<mutex> lock(_connMutex);
_userConnMap.insert({id, conn}); // 登录成功记录用户连接信息
}
user.setState("online");
_userModel.updateState(user); // 更新用户状态信息
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
response["name"] = user.getName();
conn->send(response.dump());
}
}
else // 该用户不存在或密码输入错误,登录失败
{
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 1;
response["errmsg"] = "用户不存在或密码错误";
conn->send(response.dump());
}
}
3.2.3 客户端异常退出处理
客户端异常退出处理: 假设用户客户端直接通过Ctrl + C中断,并没有给服务器发送合法的json过来,我们必须及时修改用户登录状态,否则后续再想登录时为"online"状态,便无法登录了。
客户端异常退出处理流程:
1、通过conn连接去_userConnMap表中查找,删除conn键值对记录;
2、将conn连接对应用户数据库的状态从"online"改为"offline";
客户端异常退出核心代码如下:
Chatserver中onConncetion方法处理:
//上报连接相关信息的回调函数:参数为连接信息
void ChatServer::onConnection(const TcpConnectionPtr &conn)
{
if (!conn->connected()) //客户端断开连接,释放连接资源 moduo库会打印相应日志
{
ChatService::instance()->clientCloseException(conn); //处理客户端异常关闭
conn->shutdown();
}
}
clientCloseException方法处理如下:
// 处理客户端异常退出
void ChatService::clientCloseException(const TcpConnectionPtr &conn)
{
User user;
{
lock_guard<mutex> lock(_connMutex);
// 1、从map表删除用户的连接信息
for (auto it = _userConnMap.begin(); it != _userConnMap.end(); ++it)
{
if (it->second == conn)
{
user.setId(it->first);
_userConnMap.erase(it);
break;
}
}
}
// 2、更新用户的状态信息
if (user.getId() != -1)
{
user.setState("offline");
_userModel.updateState(user);
}
}
我们测试一下:来瞧一瞧用户登录状态的变化。
3.2.4 服务器异常退出处理
服务器异常退出处理: 假设用户服务器端直接通过Ctrl + C中断,并没有给客户端发送合法的json过去,我们必须及时修改所有用户登录状态为"offline",否则后续再想登录时为"online"状态,便无法登录了。
服务器异常退出处理流程: 主动截获Ctrl + c信号(SGINIT),在信号处理函数中将数据库中用户状态重置为"offline"。
服务器异常退出核心代码如下:
main.cpp截获信号处理:
//服务器异常退出处理:重置user的状态信息
void resetHandler(int)
{
ChatService::instance()->reset();
exit(0);
}
int main()
{
signal(SIGINT, resetHandler);
EventLoop loop;
InetAddress addr("127.0.0.1", 6000);
ChatServer server(&loop, addr, "ChatServer");
server.start(); //服务器启动,开启事件循环
loop.loop();
return 0;
}
业务层调用:实质上还是调用数据层接口。
//处理服务器异常退出:业务重置
void ChatService::reset()
{
//将online状态的用户设置为offline
_userModel.resetState();
}
数据层resetState方法:
//重置用户的状态信息
void UserModel::resetState()
{
//1、组装SQL语句
char sql[1024] = "update user set state = 'offline' where state = 'online'";
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
mysql.update(sql);
}
}
我们来测试一下服务器异常断开(Ctrl + c)情况:可以看到能够及时修改用户登录状态。
3.3 点对点聊天业务
点对点聊天: 源用户向目的用户发送消息,目的用户若在线则将消息发出,目的用户若不在线将消息存储至离线消息表中,待目的用户上限后离线消息发出。
在进行点对点聊天业务处理前,我们需要提前处理好以下几点:
1、我们需要在消息类型EnMsgType中增加一个聊天消息类型,给客户端标识此时是一个聊天消息:
//消息类型
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
LOGIN_MSG_ACK, //登录响应消息
REG_MSG, //注册消息
REG_MSG_ACK, //注册响应消息
ONE_CHAT_MSG //聊天消息
};
2、将点对点业务的消息id与对应的事件处理器提前在聊天服务器业务类的构造函数里绑定好。
// 构造函数:注册消息以及对应的回调操作 实现网络模块与业务模块解耦的核心
_msgHandlerMap.insert({ONE_CHAT_MSG, std::bind(&ChatService::oneChat, this, _1, _2, _3)});
服务器点对点聊天业务流程:
1、源id向目的id发送消息时候,消息里会包含消息类型、源id、源用户名、目的id、消息内容,服务端解析到这些数据后,先获取到目的id字段。
2、找到目的id判断是否在线,若在线则服务器将源id的消息中转给目的id;若不在线则将消息内容存入离线消息表中,待目的id上线后离线消息发出。
点对点聊天业务核心代码如下:
//处理一对一聊天业务
void ChatService::oneChat(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、先获取目的id
int toid = js["toid"].get<int>();
{
lock_guard<mutex> lock(_connMutex);
auto it = _userConnMap.find(toid);
if (it != _userConnMap.end()) //2、目的id在线,进行消息转发,服务器将源id发送的消息中转给目的id
{
it->second->send(js.dump());
return;
}
}
//目的id不在线,将消息存储到离线消息里
_offlineMsgModel.insert(toid, js.dump());
}
我们测试一下两个用户之间的消息发送:点对点消息能够互相正常发送接收。
3.4 离线消息业务
离线消息业务: 当用户一旦登录成功,我们查询用户是否有离线消息要发送,若有则发送相应数据,发送完后删除本次存储的离线数据,防止数据重复发送。
在进行点对点聊天业务处理前,我们需要提前处理好以下几点:
1、建立与离线消息表的映射OfflineMsgModel类:我们数据库中有创建的OfflineMessage离线消息表,因为我们数据层与业务层要分离开来,所以这里与前面一样提供离线消息表的数据操作类,提供给业务层对应的操作接口。
离线消息offlinemessagemodel.hpp头文件:
#ifndef OFFLINEMESSAGEMODLE_H
#define OFFIINEMESSAGEMODEL_H
#include <iostream>
#include <string>
#include <vector>
using namespace std;
//离线消息表的数据操作类:针对表的增删改查
class OfflineMsgModel
{
public:
//存储用户的离线消息
void insert(int userid, string msg);
//删除用户的离线消息
void remove(int userid);
//查询用户的离线消息:离线消息可能有多个
vector<string> query(int userid);
};
#endif
离线消息offlinemessagemodel.cpp文件:
#include <offlinemessagemodel.hpp>
#include <db.h>
//存储用户的离线消息
void OfflineMsgModel::insert(int userid, string msg)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "insert into offlinemessage values(%d, '%s')", userid, msg.c_str());
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
mysql.update(sql);
}
}
//删除用户的离线消息
void OfflineMsgModel::remove(int userid)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "delete from offlinemessage where userid=%d", userid);
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
mysql.update(sql);
}
}
//查询用户的离线消息:离线消息可能有多个
vector<string> OfflineMsgModel::query(int userid)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "select message from offlinemessage where userid = %d", userid);
//2、发送SQL语句,进行相应处理
MySQL mysql;
vector<string> vec; //存储离线消息,离线消息可能有多条
if (mysql.connect())
{
MYSQL_RES *res = mysql.query(sql);
if (res != nullptr)
{
MYSQL_ROW row;
while ((row = mysql_fetch_row(res)) != nullptr) //循环查找离线消息
{
vec.push_back(row[0]);
}
mysql_free_result(res);
}
}
return vec;
}
2、业务层chatservice.hpp中增加离线消息表的数据操作类对象,方面我们后续对数据库进行操作。
OfflineMsgModel _offlineMsgModel; //数据操作类对象
服务器离线消息业务流程:
1、无论是一对一聊天、还是群聊,若接收方用户不在线,则将发送方消息先存储至离线消息表里。
2、一旦接收方用户登录成功,检查该用户是否有离线消息(可能有多条),若有则服务器将离线消息发送给接收方用户。
3、服务器发送完成后删除本次存储的离线消息,保证接收方不会每次登录都收到重复的离线消息。
离线消息业务核心代码如下:
//登录成功,查询该用户是否有离线消息
vector<string> vec = _offlineMsgModel.query(id);
if (!vec.empty())
{
response["offlinemsg"] = vec; //查询到离线消息,发送给用户
_offlineMsgModel.remove(id); //发送离线消息完成,将本次的离线消息删除掉
}
我们测试一下离线消息的发送:此时张三在线,李四不在线,张三给李四发送了两条消息。
李四一登陆,啪,离线消息就来了,此时离线消息表中数据也被清除了。
3.5 添加好友业务
添加好友业务: 客户端发送源用户id、目的用户id发送给服务器,服务器在数据库中进行好友关系的添加。添加完成用户登录后,服务器返回好友列表信息给用户,用户可以依据好友列表进行聊天,这里实现的比较简单,后续可扩充更细化的业务。
在进行添加好友业务处理前,我们需要提前处理好以下几点:
1、我们需要在消息类型EnMsgType中增加一个聊天消息类型,给客户端标识此时是一个添加好友消息:
//消息类型
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
LOGIN_MSG_ACK, //登录响应消息
REG_MSG, //注册消息
REG_MSG_ACK, //注册响应消息
ONE_CHAT_MSG, //聊天消息
ADD_FRIEND_MSG //添加好友消息
};
2、将添加好友业务的消息id与对应的事件处理器提前在聊天服务器业务类的构造函数里绑定好。
_msgHandlerMap.insert({ADD_FRIEND_MSG, std::bind(&ChatService::addFriend, this, _1, _2, _3)});
3、建立好友表与类的映射FriendModel类:表中userid与friendid关系只需要存储一次即可,因此为联合主键。这里与前面一样提供好友表的数据操作类,提供给业务层对应的操作接口。
好友表friendmodel.hpp头文件:
#ifndef FRIENDMODEL_H
#define FRIENDMODEL_H
#include "user.hpp"
#include <vector>
using namespace std;
//Friend用户表的数据操作类:针对表的增删改查
class FriendModel
{
public:
//添加好友关系
void insert(int userid, int friendid);
//返回用户好友列表:返回用户好友id、名称、登录状态信息
vector<User> query(int userid);
};
#endif
好友表friendmodel.cpp文件:
#include "friendmodel.hpp"
#include "db.h"
//添加好友关系
void FriendModel::insert(int userid, int friendid)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "insert into friend values(%d, %d)", userid, friendid);
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
mysql.update(sql);
}
}
//返回用户好友列表:返回用户好友id、名称、登录状态信息
vector<User> FriendModel::query(int userid)
{
//1、组装SQL语句:多表联合查询
char sql[1024] = {0};
sprintf(sql, "select a.id,a.name,a.state from user a inner join friend b on b.friendid = a.id where b.userid=%d", userid);
//2、发送SQL语句,进行相应处理
vector<User> vec;
MySQL mysql;
if (mysql.connect())
{
MYSQL_RES *res = mysql.query(sql);
if (res != nullptr)
{
MYSQL_ROW row;
while ((row = mysql_fetch_row(res)) != nullptr) //将userid好友的详细信息返回
{
User user;
user.setId(atoi(row[0]));
user.setName(row[1]);
user.setState(row[2]);
vec.push_back(user);
}
mysql_free_result(res);
}
}
return vec;
}
4、业务层chatservice.hpp中增加好友表的数据操作类对象,方面我们后续对数据库进行操作。
FriendModel _friendModel; //好友表的数据操作类对象
服务器添加好友业务流程:
1、服务器获取当前用户id、要添加好友的id;
2、业务层调用数据层接口往数据库中添加相应好友信息;用户登录成功时,查询该用户的好友信息并返回。
服务器添加好友业务核心代码如下:
//添加好友业务
void ChatService::addFriend(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取当前用户id、要添加好友id
int userid = js["id"].get<int>();
int friendid = js["friendid"].get<int>();
//2、数据库中存储要添加好友的信息
_friendModel.insert(userid, friendid);
}
用户登录成功时,将该用户好友列表信息返回。
//登录成功,查询该用户的好友信息并返回
vector<User> userVec = _friendModel.query(id);
if (!userVec.empty())
{
vector<string> vec2;
for (User &user : userVec)
{
json js;
js["id"] = user.getId();
js["name"] = user.getName();
js["state"] = user.getState();
vec2.push_back(js.dump());
}
response["friends"] = vec2;
}
我们测试一下添加好友业务:用户1添加用户2为好友,可以看到成功添加到friend表中。
3.6 群组业务
群组业务: 群组业务分为三块,群管理员创建群组、组员加入群组与群组聊天功能。
在进行群组业务处理前,我们需要提前处理好以下几点:
1、我们需要在消息类型EnMsgType中增加不同的消息类型,创建群组、加入群组、群组聊天三种类型消息,给客户端标识此时要做什么事情:
//消息类型
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
LOGIN_MSG_ACK, //登录响应消息
REG_MSG, //注册消息
REG_MSG_ACK, //注册响应消息
ONE_CHAT_MSG, //聊天消息
ADD_FRIEND_MSG, //添加好友消息
CREATE_GROUP_MSG, //创建群组
ADD_GROUP_MSG, //加入群组
GROUP_CHAT_MSG //群组聊天
};
2、将群组业务的消息id分别与对应的事件处理器提前在聊天服务器业务类的构造函数里绑定好。
_msgHandlerMap.insert({CREATE_GROUP_MSG, std::bind(&ChatService::createGroup, this, _1, _2, _3)});
_msgHandlerMap.insert({ADD_GROUP_MSG, std::bind(&ChatService::addGroup, this, _1, _2, _3)});
_msgHandlerMap.insert({GROUP_CHAT_MSG, std::bind(&ChatService::groupChat, this, _1, _2, _3)});
3、建立群组表与类的映射Group类与群组表的数据操作类GroupModel:提供给业务层对应操作接口。
群组表的映射类group.hpp:
#ifndef GROUP_H
#define GROUP_H
#include "groupuser.hpp"
#include <string>
#include <vector>
using namespace std;
#endif
//Group群组表的映射类:映射表的相应字段
class Group
{
public:
Group(int id = -1, string name = "", string desc = "")
{
this->id = id;
this->name = name;
this->desc = desc;
}
void setId(int id) {this->id = id;}
void setName(string name) {this->name = name;}
void setDesc(string desc) {this->desc = desc;}
int getId() {return this->id;}
string getName() {return this->name;}
string getDesc() {return this->desc;}
vector<GroupUser> &getUsers() {return this->users;}
private:
int id; //群组id
string name; //群组名称
string desc; //群组功能描述
vector<GroupUser> users; //存储组成员
};
群组表的数据操作类groupmodel.hpp:
#ifndef GROUPMODEL_H
#define GROUPMODEL_H
#include "group.hpp"
#include <string>
#include <vector>
using namespace std;
//群组表的数据操作类:维护群组信息操作接口方法
class GroupModel
{
public:
//创建群组
bool createGroup(Group &group);
//加入群组
void addGroup(int userid, int groupid, string role);
//查询用户所在群组信息
vector<Group> queryGroups(int userid);
//根据指定的groupid查询群组用户id列表,除userid自己,给群组其它成员群发消息
vector<int> queryGroupUsers(int userid, int groupid);
};
#endif
群组表的数据操作类groupmodel.cpp:
#include "groupmodel.hpp"
#include "db.h"
//创建群组:在数据库层面即给群组allgroup表中添加一组信息,不能重复添加组
bool GroupModel::createGroup(Group &group)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "insert into allgroup(groupname, groupdesc) values('%s', '%s')", group.getName().c_str(), group.getDesc().c_str());
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
if (mysql.update(sql))
{
group.setId(mysql_insert_id(mysql.getConnection()));
return true;
}
}
return false;
}
//加入群组:即给群组员groupuser表添加一组信息
void GroupModel::addGroup(int userid, int groupid, string role)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "insert into groupuser values(%d, %d, '%s')", groupid, userid, role.c_str());
//2、发送SQL语句,进行相应处理
MySQL mysql;
if (mysql.connect())
{
mysql.update(sql);
}
}
//查询用户所在群组信息:群信息以及组员信息
vector<Group> GroupModel::queryGroups(int userid)
{
/*
1、先根据userid在groupuser表中查询出该用户所属的群组详细信息
2、再根据群组信息,查询属于该群组的所有用户的userid,并且和user表进行多表联合查询出用户的详细信息
*/
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "select a.id,a.groupname,a.groupdesc from allgroup a inner join \
groupuser b on a.id = b.groupid where b.userid=%d", userid);
//2、发送SQL语句,进行相应处理
vector<Group> groupVec;
MySQL mysql;
if (mysql.connect())
{
MYSQL_RES *res = mysql.query(sql);
if (res != nullptr)
{
MYSQL_ROW row;
//查出userid所有的群信息
while ((row = mysql_fetch_row(res)) != nullptr)
{
Group group;
group.setId(atoi(row[0]));
group.setName(row[1]);
group.setDesc(row[2]);
groupVec.push_back(group);
}
mysql_free_result(res);
}
}
//查询群组的用户信息
for (Group &group : groupVec)
{
sprintf(sql, "select a.id,a.name,a.state,b.grouprole from user a \
inner join groupuser b on b.userid = a.id where b.groupid=%d", group.getId());
MYSQL_RES *res = mysql.query(sql);
if (res != nullptr)
{
MYSQL_ROW row;
while ((row = mysql_fetch_row(res)) != nullptr)
{
GroupUser user;
user.setId(atoi(row[0]));
user.setName(row[1]);
user.setState(row[2]);
user.setRole(row[3]);
group.getUsers().push_back(user);
}
mysql_free_result(res);
}
}
}
//根据指定的groupid查询群组用户id列表,除userid自己,给群组其它成员群发消息
vector<int> GroupModel::queryGroupUsers(int userid, int groupid)
{
//1、组装SQL语句
char sql[1024] = {0};
sprintf(sql, "select userid from groupuser where groupid = %d and userid != %d", groupid, userid);
//2、发送SQL语句,进行相应处理
vector<int> idVec;
MySQL mysql;
if (mysql.connect())
{
MYSQL_RES *res = mysql.query(sql);
if (res != nullptr)
{
MYSQL_ROW row;
while ((row = mysql_fetch_row(res)) != nullptr)
{
idVec.push_back(atoi(row[0]));
}
mysql_free_result(res);
}
}
return idVec;
}
4、建立群组员表的映射GroupUser类:群组和组员是多对多关系,需要这张中间表体现他们的关系,同时封装该类提供给业务层对应操作接口。
群组员表的映射类groupuser.hpp:
#ifndef GROUPUSER_H
#define GROUPUSER_H
#include "user.hpp"
//GroupUser群组员表的映射类:映射表的相应字段
class GroupUser : public User
{
public:
void setRole(string role) {this->role = role;}
string getRole() {return this->role;}
private:
string role; //组内角色
};
#endif
5、业务层chatservice.hpp中增加群组相关的数据操作类对象,方面我们后续对数据库进行操作。
GroupModel _groupModel; //群组相关的数据操作类对象
3.6.1 创建群组
服务器创建群组业务业务流程:
1、服务器获取创建群的用户id、要创建群名称、群功能等信息;
2、业务层创建数据层对象,调用数据层方法进行群组创建,创建成功保存群组创建人信息;
服务器创建群组业务核心代码如下:
//创建群组业务
void ChatService::createGroup(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取创建群的用户id、群名称、群功能
int userid = js["id"].get<int>();
string name = js["groupname"];
string desc = js["groupdesc"];
//2、存储新创建的群组信息
Group group(-1, name, desc);
if (_groupModel.createGroup(group))
{
_groupModel.addGroup(userid, group.getId(), "creator"); //存储群组创建人信息
}
}
3.6.2 加入群组
服务器组员加入群组业务流程:
1、服务器获取要加入群用户的id、要加入的群组id;
2、业务层调用数据层方法将普通用户加入;
服务器组员加入群组业务核心代码如下:
//加入群组业务
void ChatService::addGroup(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取要加入群用户的id、要加入的群组id
int userid = js["id"].get<int>();
int groupid = js["groupid"].get<int>();
//2、将用户加入群组
_groupModel.addGroup(userid, groupid, "normal");
}
3.6.3 群组聊天
服务器群组聊天业务流程:
1、获取要发送消息的用户id、要发送的群组id;
2、查询该群组其它用户id;
3、查询同组用户id,若用户在线则发送消息;若用户不在线则存储离线消息;
服务器群组聊天业务核心代码如下:
//群组聊天业务
void ChatService::groupChat(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取要发送消息的用户id、要发送的群组id
int userid = js["id"].get<int>();
int groupid = js["groupid"].get<int>();
//2、查询该群组其它用户id
vector<int> useridVec = _groupModel.queryGroupUsers(userid, groupid);
//3、进行用户查找
lock_guard<mutex> lock(_connMutex);
for (int id : useridVec)
{
auto it = _userConnMap.find(id);
if (it != _userConnMap.end()) //用户在线,转发群消息
{
it->second->send(js.dump());
}
else //用户不在线,存储离线消息
{
_offlineMsgModel.insert(id, js.dump());
}
}
}
3.7 注销业务
注销业务: 客户端用户正常退出,更新其在线状态。
在进行注销业务处理前,我们需要提前处理好以下几点:
1、我们需要在消息类型EnMsgType中增加一个注销业务类型,给客户端标识此时是一个注销业务消息:
//消息类型
enum EnMsgType{
LOGIN_MSG = 1, //登录消息
LOGIN_MSG_ACK, //登录响应消息
LOGIN_OUT_MSG //注销消息
REG_MSG, //注册消息
REG_MSG_ACK, //注册响应消息
ONE_CHAT_MSG, //聊天消息
ADD_FRIEND_MSG, //添加好友消息
CREATE_GROUP_MSG, //创建群组
ADD_GROUP_MSG, //加入群组
GROUP_CHAT_MSG, //群组聊天
};
2、将注销业务的消息id与对应的事件处理器提前在聊天服务器业务类的构造函数里绑定好。
_msgHandlerMap.insert({LOGIN_OUT_MSG, std::bind(&ChatService::loginOut, this, _1, _2, _3)});
服务器注销业务业务流程:
1、服务器获取要注销用户的id,删除其对应的连接。
2、更新用户状态信息,从在线更新为离线。
服务器注销业务核心代码如下:
//处理注销业务
void ChatService::loginOut(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取要注销用户的id,删除对应连接
int userid = js["id"].get<int>();
{
lock_guard<mutex> lock(_connMutex);
auto it = _userConnMap.find(userid);
if (it != _userConnMap.end())
{
_userConnMap.erase(it);
}
}
//2、更新用户状态信息
User user(userid, "", "", "offline");
_userModel.updateState(user);
}