本文将从涉及到主要技术开始,讲解使用Qt来实现一个支持多客户端链接的多线程TCP服务器及其客户端的设计与实现的解决方案。
注:本文使用的开发环境为Qt5.15.2, 使用MSVC2019_64编译器, C++11及以上
接下来我将会详细讲解客户端和服务端的设计与实现的关键细节。完整的源代码在文章最后。
注:如果你已经熟知Qt的TCP套接字的使用可以跳过这一部分。
Qt的TCP套接字编程相较于传统的套接字编程更加的简单方便。而且,其与Qt自身的信号槽机制和多线程相结合使得异步无阻塞的套接字编程实现更加的简单。如果你了解TCP协议的基本流程,以及传统的TCP套接字编程,那么Qt更加简单的语法将会使你欲罢不能。
首先来解释一下,“为什么有一个QTcpServer,传统的不是只有一个socket吗?”
其实QTcpServer和QTcpSocket都是对原生的socket的封装,只是QTcpServer对服务器接收新连接的socket做了更好的处理。
如果是原生的socket,你一定也会创建两个socket,一个负责监听并处理新的socket接入,另一个根据监听来创建与客户端的TCP连接,使得服务器可以与客户端通讯。如果只有一个socket那么一个客户端接入之后服务端变失去了监听并接受新的连接的效能。
所以,在Qt中直接将这样的两种职能的socket封装成了QTcpServer与QTcpSocket。
见名知意其是处理TCP套接字的服务器端,与套接字本身并无太大的区别,Qt对其的封装使得你可以更好的实现客户端套接字的接入处理,而不同于传统的套接字编程都是直接使用。通过下面的语句即可创建一个QTcpServer
QTcpServer *tcpServer = new QTcpServer();
创建完成一个服务端的套接字,为了使其监听指定的IP和端口,使用 listen()方法即可
tcpServer->listen(QHostAddress::Any, quint16(8848));
这样便可以监听从 任何IP 发到 本机8848端口 的TCP套接字
不同于传统的socket编程需要先绑定bind()再启动监听listen(),Qt将其封装在一起.
当有新的TCP套接字接入时,即有一个QTcpSocket请求连接时,
相应的QTcpServer将会自动创建一个与客户端连接的QTcpSocket的SocketDescriptor,并发出newConnection()信号。描述符你可以理解成标识码,即每个SocketDescriptor表示了不同的socket连接的具体信息,其本质是一个指针指向了一个QTcpSocket的核心数据块。
使用如下方法,即可让一个空的QTcpSocket完成配置,使其与客户端的socket连接。如果连接成功该QTcpSocket会发出一个connected()信号,客户端相应的socket也会发出同样的信号。
QTcpSocket *tcpSocket = new QTcpSocket();
tcpSocket = tcpServer->nextPendingConnection();
相应的信号槽可以这么写
connect(tcpServer, &QTcpServer::newConnection(), [&](){
tcpSocket = tcpServer->nextPendingConnection();
});
创建一个QTcpSocket,并发出连接请求
QTcpSocket *tcpSocket = new QTcpSocket();
tcpSocket->connectToHost(QHostAddress("127.0.0.1"), quint16(8848));
如果成功,那么tcpSocket将发出connected()信号。
发送数据使用write()方法即可
tcpSocket->write("Hello World!");
QString mesg = "Hello World!";
tcpSocket->write(mesg.toUtf8().data());
当服务器回送消息到达客户端后,客户端相应的QTcpSocket将会发出readyRead()信号,使用readAll()即可读取内容。
connect(tcpSocket, &QTcpSocket::readyRead, [&](){
QString mesg = tcpSocket->readAll();
});
消息传送完毕后,使用disconnectedFromHost()方法来断开TCP连接,客户端和服务端对应的套接字都会发出disconnected()信号。
tcpSocket->disconnectFromHost();
connect(tcpSocket, &QTcpSocket::disconnected, [&](){
qDebug() << "Disconnected!";
});
一个常见的问题,我看到(同时这让我畏缩)人们了解和使用Qt线程和如何对代码做一些他们认为正确的工作。人们展现自己的代码,或用自己的代码写例子,往往最终让我的思维定格在:
你这样做是错误的
—— Bradley T. Hughes:"You’re doing it wrong"
https://blog.csdn.net/hustyangju/article/details/9493485
如果你已经对多线程有了一定的了解请先看看上面这篇文章,然后看看下面这篇转载的文章(没有找到原文,但是这篇转载的质量很高)。如果你跟我一样是实现多线程时遇到了麻烦,那么这两篇文章应该可以解开你的所有疑惑,而不用看完本文余下的内容,就可以继续你自己的实现。
https://blog.csdn.net/lynfam/article/details/7081757
如果你还不甚熟悉Qt的多线程,那就忽略这些,继续看下去。
Qt中的多线程有两种写法,第一种是自定义一个类(例如ChatThread)继承于QThread,重写run()方法来实现。run()就是你希望线程干什么事就写在这里。
例如:
#include
#include
class ChatThread : public QThread
{
Q_OBJECT
public:
explicit ChatThread(QObject *parent = nullptr);
void run() override;
signals:
void showSignal();
};
ChatThread::ChatThread(QObject *parent) : QThread{parent}
{
}
void ChatThread::run()
{
emit showSignal();
}
这样你就自定义了一个线程,接下来在主线程中,创建线程,并使用start()启动它就行了
#include
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
ChatThread* chatThread = new ChatThread();
QObject::connect(chatThread, &ChatThread::showSignal, [&](){
qDebug() << "ChatThread running!";
});
chatThread->start();
return a.exec();
}
但是,一般不建议使用这种方法。
推荐下面的方法
第二种方法:使用moveToThread()方法
我们认为QThread只是程序的一种控制转移。就是说我们应该将一些复杂的、耗时长的计算、业务处理与GUI界面分离,放入QThread中来正确的使用线程。而不是继承QThread,再在继承的类(例如ChatThread)中实现业务逻辑。
更加正确的写法应该是自定义继承QObject的业务类实现业务逻辑:
class ChatBusiness : public QObject
{
Q_OBJECT
public:
explicit ChatBusiness(QObject *parent = nullptr);
void mainBusiness();
signals:
void startSignal();
};
ChatBusiness::ChatBusiness(QObject *parent) : QThread{parent}
{
connect(this, &ChatBusiness::startSignal, this, &ChatBusiness::mainBusiness);
}
在需要的地方创建,移入线程中,然后发出信号启动线程
void someFunction()
{
//some code
//need that time cost process(ChatBusiness)
ChatBusiness *chatBusiness = new chatBusiness();
QThread *thread = new thread();
chatBusiness->moveToThread(thread);
emit chatBusiness->startSignal();//start the thread
}
下面这篇文章很好的阐释了信号槽与多线程以及事件循环的相关内容。
Qt 的线程与事件循环:https://blog.csdn.net/lynfam/article/details/7081757
简单来说,
而我们需要实现的多线程TCP就是属于第二类。
我们这里只是实现简单的多线程TCP服务器。就是说新的链接接入,就为其创建一个线程单独处理。所以我们需要自定义新链接接入的处理逻辑。
这一点可以通过继承QTcpServer,实现自己的MyTcpServer并重写incomingConnection(qintptr handle)来实现
void MyTcpServer::incomingConnection(qintptr handle)
{
//create subThreaad
QThread *thread = new QThread(this);
ChatBusiness *chatBusiness = new ChatBusiness();
chatBusiness->moveToThread(thread);
//def handle of start
connect(chatBusiness, &ChatBusiness::start, chatBusiness, &ChatBusiness::mainBusiness);
thread->start();
//send the SocketDescriptor
emit chatBusiness->start(handle);
}
void ChatBusiness::mainBusiness(qintptr handle)
{
QTcpSocket *tcpSocket = new QTcpSocket(this);
tcpSocket->setSocketDescriptor(handle);
}
上面代码中,在子线程中业务处理类的处理开始是mainBusiness(qintptr handle)方法,其将传递的SocketDescriptor赋给创建的socket
至此你便了解所有的基本核心技术
从功能演示中我们看到,用户通过客户端发送自己的昵称,服务器进行认证注册;用户输入发送的目标,点击发送,服务器转发消息到相应的用户。其通讯流程如下图设计:
由于是直接使用的TCP通讯,而不是应用层协议,需要自己根据需要设计通讯格式。
在本程序中通讯格式设计成:
服务器回送系统消息:
[username,15]@[Server,15] [ServerMesgType,20]
ServerMesgType | 解释 |
---|---|
RegisterSuccessful | 名称注册成功 |
RegisterFailed | 名称注册失败,重名 |
TargetOffline | 接收方不在线 |
服务器转发用户消息:
[targetName,15]@[username,15] [Mesg]
用户发送系统消息(注册名称):
[username,15]@[Server,15] [ServerMesgType,20]
ServerMesgType | 解释 |
---|---|
Register | 请求注册名称 |
登录窗口
聊天主窗口
此外为了保证UI显示在不同分辨率的显示器、不同的缩放比例下有着较好的适配,我们需要如下设置
添加新的qrc
然后在文件夹中创建 /etc/qt.conf 文件
qt.conf 文件中添加如下内容
[Platforms]
WindowsArguments = dpiawareness=0
WindowsArguments = fontengine=freetype
第一行是让程序的缩放程度跟随系统控制
第二行是使用freetype字体,使得缩放时的字体不会出现明显的锯齿
同时在main.cpp文件中添加,来使得缩放时图像不会有锯齿
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
客户端的网络实现比较简单,只需要使用QTcpSocket。实现其readyRead(),connected(),disconnected()信号的处理即可。
这里不再赘述。
无论从功能需要还是性能要求,服务端做成控制台应用即可,无需使用Qt的界面。
这样客户端和服务器的设计和实现细节便全部描述完毕。你可以一次尝试自己开发,或者在文章的最后部分下载工程源码
在完成编码后我们需要测试,或者给朋友玩玩之类,就需要对程序打包部署。
首先我们以Release模式重新构建项目,然后将构建好的对应的.exe文件拷贝到一个单独的文件夹
例如.../Client/client-Demo.exe
然后在Qt的 安装目录 找到项目使用的对应的 编译器文件夹
例如:
确认有该工具
在该目录打开命令行,输入.\windeployqt.exe Client.exe的完整路径
,例如
.\windeployqt.exe C:\Client\Client-Demo2.exe
之后使用Enigma Virtual Box打包即可,如果在其他电脑上不能运行。。。那就根据提示再打包。例如提示找不到xxxx.dll
,就把该dll放到Client.exe同级文件夹下,再使用Enigma Virtual Box打包。
嗯,是的,感觉很是愚蠢。但是这是相对来说比较省事的方法了。
如果涉及Qt之外的库,那就需要用depends.exe来查找依赖了。
可以参考这篇文章:QT+Opencv 程序打包发布:https://blog.csdn.net/qq_43599883/article/details/106251915
部分内容已经在下一个版本的开发日程中,之后会在本博客中更新新的工程源码,欢迎持续关注。ヾ(≧▽≦*)o
版本1:https://github.com/SWULWJ/AirChat
版本2:待定
QT 使用全局缩放进行全分辨率适配(QT_SCALE_FACTOR):https://blog.csdn.net/u014410266/article/details/107488789
Qt Windows高清DPI自适应分辨率缩放:https://blog.csdn.net/startl/article/details/105862817
Qt 的线程与事件循环:https://blog.csdn.net/lynfam/article/details/7081757
Qt多线程中的信号与槽:https://blog.csdn.net/qq_29344757/article/details/78136829
TCP_UDP_Assistant.zip (一个很厉害的例子):https://download.csdn.net/download/yxy244/12030948
QT+Opencv 程序打包发布:https://blog.csdn.net/qq_43599883/article/details/106251915
Qt应用程序在windows和Linux操作系统下的打包发布:https://blog.csdn.net/qq_41488943/article/details/104963916
RSA算法原理:https://zhuanlan.zhihu.com/p/48249182
AES加密算法的详细介绍与实现:https://blog.csdn.net/qq_28205153/article/details/55798628
感谢阅读!
有疑问或者认为有错误请留言,谢谢!