花了一周时间开发了一个简单的即时通信工具,勉强算是程序原型。现在我把开发流程和一些个人的想法记录下来。本文首先介绍程序架构和通信接口,之后会聚焦到服务器的信号槽设计原则,接下来将解释有关TCP通信的粘包问题和解决方案,最后一个部分是一些改进建议。
源码下载:https://gitee.com/learnhow/imlite
程序架构图例:
一、架构方案介绍
主程序(MainWindow)启动服务器(TcpServer),在收到客户端的连接请求之后会开启一个线程并创建子连接(TcpSocket)。目前支持的消息类型有三种,分别为:Message——由客户端发起的端到端消息或直接由服务器发起的广播消息,这是即时通信最基础的通信要求。MessageConnCallback——由服务器向新连接的终端发起的socketID反向注册信息,目的是告知客户端本次连接在服务器端生成的socketID。MessageConnecter——由服务器向所有终端发布的一条广播,通知所有终端其他的终端socketID和客户端自定义的nickname,这是实现端到端通信的基础。
端到端通信的原理实际上是由客户端发送一条携带了接收方socketID的信息。服务器在收到后会解析并进行转发。
为了让多种消息类型能够统一,程序提供了MessageInterface接口和TcpSocketData对象。所有的消息类型都必须实现MessageInterface接口,并且在发送端和接收端都必须通过TcpSocketData来注册消息类型包括进行序列化和反序列化。
NetPacket提供了消息打包和解包方法。为发送的数据包加上一个字符串作为包尾,并且在接收端可以根据包尾来解包并分割为原始报文。更详细的信息将在第三节介绍。
SQLiteService实现了一种简单的数据保存方法,由于程序开发的重点不在于此,并没有做专门设计。二次开发中应该会重新设计,这里一带而过。
二、通信机制介绍
本例中所有对象的通信都采用信号槽,信号槽是Qt提供的一种消息流转机制。在命名规则上,程序按照Qt的命名方案:槽的命名使用动词一般现在时,信号命名使用动词过去时。并且以“谁创建谁连接”的原则编写,例如:TcpServer负责创建所有的子连接TcpSocket,因此与它的信号槽连接都会放在TcpServer中实现。
接下来以客户端向服务器端发起连接请求为例做简单介绍。服务器(TcpServer)通过incomingConnection(qintptr)方法产生socketID,立刻通过子连接向终端发送connectionCallback信号,接着会发送广播向终端通知有新的连接加入,最后还会向UI层发送一个与显示有关的信号(程序中表现为在QWidgetList中增加一个Item)。
// 当有新的终端连接请求时被调用
void TcpServer::incomingConnection(qintptr handle)
{
... clientStatusConnect(QString("%1").arg(handle));
} void TcpServer::clientStatusConnect(QString socketDescriptor)
{ socketnicknamemap.insert(socketDescriptor, "Guest");
// 向终端反向注册handle
MessageConnCallback callback;
callback.setSocketDescriptor(socketDescriptor);
emit connectionCallback(callback); ① MessageConnecter msgConn;
msgConn.setConnectors(socketnicknamemap);
emit terminalsPublished(msgConn); ② // 反馈信号至UI:增加终端
emit clientStatusTriggered(socketDescriptor, );
}
其它的代码就不一一解释了。
三、Tcp粘包和解决方案
观察上面的源码片段,当有新连接产生后,服务器会“同时”向终端发送两条数据(注:①②)。客户端的void dataRecv()方法会调用QByteArray buff = tcpSocket->readAll()读取数据,这时有极大的几率发生“粘包”——服务器发送的两条报文A、B,客户端以A+B作为一条报文读取出来。我的解决方案是在发送端为每段报文增加一个字符串(CF07D)作为结束标志,在接收端就以此标志对报文分割。
QByteArray NetPacket::package(const QByteArray &data)
{
QByteArray wrap(data);
wrap += splite;
return wrap;
} QList<QByteArray> NetPacket::unpackage(const QByteArray &wrap)
{
QList<QByteArray> bufflist;
int pos = ;
int prev = ;
while((pos = wrap.indexOf(splite, pos)) != -) {
QByteArray part = wrap.mid(prev, pos);
bufflist.push_back(part);
pos += splite.length();
prev = pos;
}
return bufflist;
}
四、问题及改进建议
(1)信号槽是一个好东西,但是不能滥用:程序中所有对象的数据流转都是通过信号槽机制。不可否认这样做似乎减少了模块耦合,但是增加了维护的难度。并且看上去也显得“乱糟糟”的。如果一条数据同时需要经过多个对象处理,就必须为此设计多个信号槽。
(2)把消息分开传输:服务器的自连接与客户端之间只使用了一个socket端口通信(8361),这样在接收端就必须为不同的消息类型设计不同的type值。更加合理的做法是让客户端与服务器同时使用多个端口进行通信,既有利于需求扩展又不至于让接收端的方法无限膨胀。