好久没有发博客,上一篇的博客还是在上一份工作离职前整理的一篇博客。大半年没有发,一是工作繁忙,转成了音视频方向,新的工作内容暂时还不便发出来,二是不知道发什么内容,也没有整理。考虑了一下是python调用C库,但是整理起来比较费劲,想想就整理这个了 内容还相对少一点,比较有意思。
这个聊天室是我上一次的一个小项目,头像,签名,群聊,登录,图片发送等等相关功能,这次就单独说一下图片发送了。
1.Qt version: 5.12.2
2.没有使用第三库
CSDN不支持plantuml,贴了一下图
聊天室收发图片时序图
@startuml
title 时序图
entity clientA as clientA
entity clientB as clientB
== 初始化 ==
clientA -> clientA : tcp-socket初始化
clientB -> clientB : tcp-socket初始化
clientA <-> clientB : tcp连接建立
== 图片收发 ==
clientA -> clientA : 选择图片
clientA -> clientB : 发送图片
clientB -> clientB : 接收图片
clientB -> clientB : 保存图片
clientB -> clientB : 显示图片
== end ==
@enduml
关键点的选择
1.建立TCP连接 : QTcpSocket ,不做说明
2.选择图片 : QFileDialog 实现图片选择
3.发送图片 : 消息拼装,QTcpSocket -> write(QByteArray)
4.接收图片 : QTcpSocket -> readyRead(),消息解析
5.显示图片 : QWidget->show()
其中,关键点为,tcp在实际的场景中,会遇到拆包,丢包,沾包等一些意外的情况,当图片文件比较大的时候,tcp的单帧数据有限,必然会发生拆包现象,所以我们在接收时需要考虑从组包的情况,把完整的图片数据提取出来。
socket = new QTcpSocket;
socketState = false;
//ipAddressStr ip地址
//port 端口号
if(!socketState)
{
socket->connectToHost(ipAddressStr, port);
if(socket->waitForConnected(3000))
{
qDebug() << "Connect2Server OK";
ui->pushButtonConnect->setText("连接成功");
socketState = true;
}
else
{
qDebug() << socket->errorString();
return;
}
}
else
{
socket->close(); //触发disconnected()信号
ui->pushButtonConnect->setText("断开连接");
socketState = false;
}
connect(socket, SIGNAL(readyRead()),this, SLOT(readyReadSlot())); //接收消息
//接收数据槽函数
void Widget::readyReadSlot()
{
QByteArray data = socket->readAll();
byteArray += data; //当前socket接收数据缓冲区,将新来的数据添加到数据缓冲区末尾
emit sign_recvData(); //触发数据解析事件
}
void Widget::sendMsg(QString msg)
{
if(socket->isOpen() && socket->isValid())
{
QByteArray _bufByteArry;
//msg -> _bufByteArry : QString 转为 QByteArray
socket->write(_bufByteArry);
}
}
void Widget::on_pushButtonSend_img_clicked()
{
QString fileName = QFileDialog::getOpenFileName(this,
tr("图片选择对话框"),
"F:",
tr("*png *jpg;"));
QImage image(fileName);
QByteArray imgBy;
QBuffer imgBuf(&imgBy);
image.save(&imgBuf, "png");
emit chartMsg(ui->groupBox->title(), true, QString::fromLocal8Bit(imgBy.toBase64())); //送入到发送区
//图片显示
QString str = QString(QDateTime::currentDateTime().toString("yyyy.MM.dd hh:mm:ss ddd")) + selfName + ":\n";
ui->textBrowserRecv->append(QString(str));
ui->textBrowserRecv->insertHtml(imgPathToHtml(fileName));
}
void UserChart::setRecvMsg(bool msgType, QString msgData)
{
QString str = QString(QDateTime::currentDateTime().toString("yyyy.MM.dd hh:mm:ss ddd")) + ui->groupBox->title() + ":\n";
if(!msgType)
{
str += msgData;
ui->textBrowserRecv->append(QString(str));
}
//如果消息类型为图片消息
else
{
QImage image;
image.loadFromData(QByteArray::fromBase64(msgData.toLocal8Bit()));
image.save(QString("./" + QDateTime::currentDateTime().toString("yyyyMMddhhmmsddd") + ".png"), "png");
ui->textBrowserRecv->append(QString(str));
ui->textBrowserRecv->insertHtml(imgPathToHtml(QString("./" + QDateTime::currentDateTime().toString("yyyyMMddhhmmsddd") + ".png")));
}
}
在上述的内容中,给出了一些的基础写法。还剩在发送的前的数据组包,接收数据后的拆包,组包等一些处理。在这些处理中,有一些关键问题。
在实际的通信过程,数据类型与内容时很复杂的,怎么确认数据是点对点的聊天数据,还是群聊的聊天数据,数据的发送人是谁,数据的接收人是谁,这些都是需要在业务过程实际的处理的一些问题。
其中涉及到的是通信数据包的数据结构的定义,以及实际的拆包组包逻辑两个关键点的解决。
为了减少开发的成本以及高效的阅读性,序列化与反序列化的成本。选择通用json,来处理实际的有效用户数据。
数据结构如下所示:
{
"sendname" : "username",
"recvname" : "username",
"msgtype" : 0, //在实际的业务处理中,消息类型只包含两种数据, 文本数据,图片数据
"msgdata" : "data"
}
{
"type" : "", //消息类型
"length" : "", //数据长度
"data" : "" //数据内容
}
以 user_msg为例全部的数据包如下:
{
"type" : "user_msg",
"length" : "",
"data" : "
{
\"sendname\" : \"username\",
\"recvname\" : \"username\",
\"msgtype\" : 0,
\"msgdata\" : \"data\"}
"
}
结构体内容如下所示:
struct UserMsg
{
QString sendName;
QString recvName;
bool msgType;
QString msgData;
QString parseJson()
{
QJsonObject jsonObj;
jsonObj.insert("sendname", sendName);
jsonObj.insert("recvname", recvName);
jsonObj.insert("msgtype", msgType);
jsonObj.insert("msgdata",msgData);
QJsonDocument jsonDoc;
jsonDoc.setObject(jsonObj);
return QString::fromUtf8(jsonDoc.toJson(QJsonDocument::JsonFormat::Compact));
}
int parseJsonObject(QString data)
{
try
{
QJsonObject j = parse(data.toLocal8Bit(), err);
if(err == QString(ERROR_UNSTR))
return KERROR;
sendName = get_value(j, "sendname").toString();
recvName = get_value(j, "recvname").toString();
msgType = get_value(j, "msgtype").toBool();
msgData = get_value(j, "msgdata").toString();
return KSUCCESS;
} catch (const std::exception) {
return KFAIL;
}
}
};
在包数据完成之后,就涉及到实际的 沾包,组包,拆包的实际处理,怎样保证或者说判断你接收的数据是一个完整的数据包,就涉及到包的校验。就是传统的 包头,包长度,包数据,包尾。
//下边的数据结构就是类似的抽象概念
struct NetMsgHeader
{
int startID;
int length;
};
struct NetMsgEnd
{
int endID;
};
struct NetMsg
{
NetMsgHeader header;
QString msg;
NetMsgEnd end;
};
#define MSG_HEAD_ID 123456 //定义包头
#define MSG_END_ID 654321 //定义包尾
void Widget::sendMsg(QString msg)
{
if(socket->isOpen() && socket->isValid())
{
NetMsg netMsg;
netMsg.header.startID = 123456; //包头赋值
netMsg.end.endID = 654321; //包尾赋值
netMsg.msg = msg; //用户数据
netMsg.header.length = sizeof(int) * 3 + netMsg.msg.length(); //数据长度
qDebug() << "SendMsg:" << msg;
QByteArray _bufByteArry;
//append 方式尾插插入数据,注意数据的转换
_bufByteArry.append((const char*)&netMsg.header.startID, sizeof(int)); //包头转为字节数组
_bufByteArry.append((const char*)&netMsg.header.length, sizeof(int)); //包长度转为字节数组
_bufByteArry.append(msg.toStdString().c_str(), msg.length()); //数据转为字节数组
_bufByteArry.append((const char*)&netMsg.end.endID, sizeof(int)); //包尾转为字节数据
// qDebug() << _bufByteArry << byteArrayToInt(_bufByteArry.mid(0, 4)) << byteArrayToInt(_bufByteArry.mid(4, 4))
// << QString::fromLocal8Bit(_bufByteArry.mid(8, (netMsg.header.length - 12))) << byteArrayToInt(_bufByteArry.mid((netMsg.header.length - 4), 4));
socket->write(_bufByteArry); //写入数据到socket
}
}
QByteArray byteArray; //声明字节型数组缓冲区,将所有接收的数据,全量保存的数据缓冲区
void Widget::readyReadSlot()
{
QByteArray data = socket->readAll(); //读取IO口缓冲区的所有数据
byteArray += data; //采用尾插的方法将数据写入数据缓冲区
emit sign_recvData(); //触发接收信号,进行数据解析
}
connect(this, &Widget::sign_recvData, this, &Widget::slt_packagetHandle); //信号槽
//数据解析
void Widget::slt_packagetHandle()
{
NetMsg netMsg;
//判断数据缓冲区的数据是否大于消息头,如果小于包头(haed + length),判断数据无效,跳出解析,继续等待下次数据到来
if(byteArray.length() >= sizeof(NetMsgHeader))
{
//取出包头
//注意提取方式
netMsg.header.startID = byteArrayToInt(byteArray.mid(MSG_DEFAULT_POSTION, sizeof(int)));
//取出包长度
netMsg.header.length = byteArrayToInt(byteArray.mid(sizeof(int), sizeof(int)));
//如果缓冲区长度大于包长度,进去数据解析
if(byteArray.length() >= netMsg.header.length)
{
//取出包尾
netMsg.end.endID = byteArrayToInt(byteArray.mid(netMsg.header.length - sizeof(int), sizeof(int)));
//校验包头包尾
if(netMsg.end.endID == MSG_END_ID && netMsg.header.startID == MSG_HEAD_ID)
{
//触发用户消息,发送到主线程进行对应的消息处理
emit sign_recvMsg(QString::fromLocal8Bit(byteArray.mid(8, netMsg.header.length - sizeof(int) * 3)));
//数据缓冲区,移除已经处理的数据
byteArray = byteArray.remove(MSG_DEFAULT_POSTION, netMsg.header.length);
//如果数据不为空,继续进行下一次解析
if(!byteArray.isEmpty())
{
emit sign_recvData();
}
}
}
else if(byteArray.length() < netMsg.header.length)
{
return;
}
}
}
上述的消息结构只满足图片发送与文本发送,在发送文件的时候,文件格式以及文件名称的确实导致文件无法保存。所以需要将消息结构进行扩张。
{
"sendname" : "username",
"recvname" : "username",
"msgtype" : 0, //在实际的业务处理中,消息类型只包含两种数据, 0:文本数据 1:图片数据 2:文件数据
"msgname" : "name", //消息名称 -0:text 1: image 2: filename.fmt
"msgdata" : "data"
}
结构体改为如下:
struct UserMsg
{
QString sendName;
QString recvName;
int msgType;
QString msgName;
QString msgData;
QString parseJson()
{
QJsonObject jsonObj;
jsonObj.insert("sendname", sendName);
jsonObj.insert("recvname", recvName);
jsonObj.insert("msgtype", msgType);
jsonObj.insert("msgname", msgName);
jsonObj.insert("msgdata",msgData);
QJsonDocument jsonDoc;
jsonDoc.setObject(jsonObj);
return QString::fromUtf8(jsonDoc.toJson(QJsonDocument::JsonFormat::Compact));
}
int parseJsonObject(QString data)
{
try
{
QJsonObject j = parse(data.toLocal8Bit(), err);
if(err == QString(ERROR_UNSTR))
return KERROR;
sendName = get_value(j, "sendname").toString();
recvName = get_value(j, "recvname").toString();
msgType = get_value(j, "msgtype").toInt();
msgName = get_value(j, "msgname").toString();
msgData = get_value(j, "msgdata").toString();
return KSUCCESS;
} catch (const std::exception) {
return KFAIL;
}
}
};
针对不同类型的文件保存,则需要一个 QFile 文件句柄,来保存文件。
文档只写了关键内容以及关键思路,如有错误或者说更好的思路,欢迎指正,以及交流。