QtGrpc 聊天
一款聊天应用程序,用于在聊天室中共享任何类型的信息。
聊天示例演示了QtGrpc 客户端 API 的高级用法。服务器可让用户注册并通过身份验证,从而加入聊天室。一旦加入,用户就可以在聊天室中与所有其他参与者共享各种类型的信息,如文本信息、图片、用户活动或磁盘中的任何其他文件。
本例中涉及的一些关键主题包括
- 通过长期存在的QGrpcBidiStreams 进行通信。
- 从工作站thread 使用QtGrpc 客户端。
- 在 protobuf 模式中使用QtProtobufQtCoreTypes 模块。
- 通过SSL 进行安全通信。
- 在 QMLListView 中可视化QtProtobuf 消息。
Protobuf 模式
Protobuf 模式定义了聊天应用中使用的消息和服务结构。模式分为两个文件:
syntax = "proto3"; package chat; import "chatmessages.proto"; service QtGrpcChat { // Register a user with \a Credentials. rpc Register(Credentials) returns (None); // Join as a registered user and exchange \a ChatMessage(s) rpc ChatRoom(stream ChatMessage) returns (stream ChatMessage) {} }
qtgrpcchat.proto
文件指定了 QtGrpcChat 服务,它提供了两个 RPC 方法:
Register
:通过提供的Credentials
注册用户。服务器以纯文本形式从数据库中存储和验证用户。ChatRoom
:建立一个双向流,用于在所有连接的客户端之间交换ChatMessage
(s)。服务器将所有接收到的信息广播给其他已连接的客户端。
syntax = "proto3"; package chat; import "QtCore/QtCore.proto"; message ChatMessage { string username = 1; int64 timestamp = 2; oneof content { TextMessage text = 3; FileMessage file = 4; UserStatus user_status = 5; } }
chatmessages.proto
文件定义了ChatMessage
,它是一个带标记的联合体(也称为总和类型)。它表示可通过ChatRoom
流 RPC 发送的所有单独消息。每个ChatMessage
都必须包含username
和timestamp
,以标识发送方。
我们使用QtCore/QtCore.proto
导入来启用QtProtobufQtCoreTypes 模块的类型,从而实现QtCore 特定类型与 Protobuf 对应类型之间的无缝转换。
message FileMessage { enum Type { UNKNOWN = 0; IMAGE = 1; AUDIO = 2; VIDEO = 3; TEXT = 4; } Type type = 1; string name = 2; bytes content = 3; uint64 size = 4; message Continuation { uint64 index = 1; uint64 count = 2; QtCore.QUuid uuid = 3; } optional Continuation continuation = 5; }
FileMessage
是 和类型支持的消息类型之一。它允许将任何本地文件封装到消息中。可选的 字段通过分块处理大文件传输来确保可靠的传输。ChatMessage
Continuation
注: 关于在 Protobuf 模式和应用程序代码中使用ProtobufQtCoreTypes
模块的更多详情,请参阅 Qt Core usage.
服务器
注意: 这里描述的服务器应用程序使用了 gRPC™库。
服务器应用程序使用异步gRPC 回调 API。这样,我们就能从异步 API 的性能优势中获益,而无需手动管理完成队列的复杂性。
class QtGrpcChatService final : public chat::QtGrpcChat::CallbackService
我们声明了QtGrpcChatService
类,它是生成的QtGrpcChat
服务的CallbackService
的子类。
grpc::ServerBidiReactor<chat::ChatMessage, chat::ChatMessage> * ChatRoom(grpc::CallbackServerContext *context) override { return new ChatRoomReactor(this, context); } grpc::ServerUnaryReactor *Register(grpc::CallbackServerContext *context, const chat::Credentials *request, chat::None * /*response*/) override
我们重载虚拟函数,以实现该服务提供的两个gRPC 方法的功能:
Register
方法验证用户并将其存储在纯文本数据库中。ChatRoom
方法根据数据库检查元数据中提供的凭证。如果成功,则建立双向流进行通信。
// Broadcast \a message to all connected clients. Optionally \a skip a client void broadcast(const std::shared_ptr<chat::ChatMessage> &message, const ChatRoomReactor *skip) { for (auto *client : activeClients()) { assert(client); if (skip && client == skip) continue; client->startSharedWrite(message); } }
服务实现会跟踪所有通过ChatRoom
方法连接或断开的活动客户端。这样,broadcast
功能就能与所有连接的客户端共享信息。为减少存储和开销,ChatMessage
被包裹在shared_ptr
中。
// Share \a response. It will be kept alive until the last write operation finishes. void startSharedWrite(std::shared_ptr<chat::ChatMessage> response) { std::scoped_lock lock(m_writeMtx); if (m_response) { m_responseQueue.emplace(std::move(response)); } else { m_response = std::move(response); StartWrite(m_response.get()); } }
startSharedWrite
方法是ChatRoomReactor
的成员函数。如果反应器(即客户端)当前正在写入,则消息会被缓冲到队列中。否则,将启动写操作。所有客户端共享一条唯一的消息。response
消息的每一份副本都会增加use_count
。一旦所有客户端都完成了消息的写入,且use_count
降为 0,其资源就会被释放。
// Distribute the incoming message to all other clients. m_service->broadcast(m_request, this); m_request = std::make_shared<chat::ChatMessage>(); // detach StartRead(m_request.get());
该代码段是ChatRoomReactor::OnReadDone
虚拟方法的一部分。每次调用该方法时,客户端都会收到一条新消息。该消息会跳过发送者广播给所有其他客户端。
std::scoped_lock lock(m_writeMtx); if (!m_responseQueue.empty()) { m_response = std::move(m_responseQueue.front()); m_responseQueue.pop(); StartWrite(m_response.get()); return; } m_response.reset();
该代码段是ChatRoomReactor::OnWriteDone
虚拟方法的一部分。每次调用该方法时,都会向客户端写入一条信息。如果队列中还有缓冲消息,则会写入下一条消息。否则,m_response
将被重置,以表示没有写入操作正在进行中。锁用于防止与broadcast
方法发生争用。
客户端
客户端应用程序使用提供的 Protobuf 模式与服务器通信。它提供前端和后端功能,用于注册用户和处理ChatRoom
gRPC 方法的长期双向流。这使得ChatMessage
s 的可视化和通信成为可能。
设置
add_library(qtgrpc_chat_client_proto STATIC) qt_add_protobuf(qtgrpc_chat_client_proto QML QML_URI QtGrpcChat.Proto PROTO_FILES ../proto/chatmessages.proto PROTO_INCLUDES $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES> ) qt_add_grpc(qtgrpc_chat_client_proto CLIENT PROTO_FILES ../proto/qtgrpcchat.proto PROTO_INCLUDES $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES> )
首先,我们根据 Protobuf 模式生成源文件。由于qtgrpcchat.proto
文件不包含任何message
定义,因此只需要生成qtgrpcgen。我们还提供了ProtobufQtCoreTypes
模块的PROTO_INCLUDES
,以确保"QtCore/QtCore.proto"
导入有效。
target_link_libraries(qtgrpc_chat_client_proto PUBLIC Qt6::Protobuf Qt6::ProtobufQtCoreTypes Qt6::Grpc )
我们确保独立的qtgrpc_chat_client_proto
目标针对其依赖项(包括ProtobufQtCoreTypes
模块)进行公开链接。然后再根据该库链接应用程序目标。
后台逻辑
应用程序的后台围绕四个关键要素构建:
ChatEngine
:管理应用程序逻辑的面向 QML 的单例。ClientWorker
:一个 Worker 对象,异步提供gRPC 客户端功能。ChatMessageModel
:用于处理和存储ChatMessage
s 的自定义QAbstractListModel
。UserStatusModel
:用于管理用户活动的自定义QAbstractListModel
。
explicit ChatEngine(QObject *parent = nullptr); ~ChatEngine() override; // Register operations Q_INVOKABLE void registerUser(const chat::Credentials &credentials); // ChatRoom operations Q_INVOKABLE void login(const chat::Credentials &credentials); Q_INVOKABLE void logout(); Q_INVOKABLE void sendText(const QString &message); Q_INVOKABLE void sendFile(const QUrl &url); Q_INVOKABLE void sendFiles(const QList<QUrl> &urls); Q_INVOKABLE bool sendFilesFromClipboard();
上面的代码段显示了从 QML 调用与服务器交互的一些Q_INVOKABLE
功能。
explicit ClientWorker(QObject *parent = nullptr); ~ClientWorker() override; public Q_SLOTS: void registerUser(const chat::Credentials &credentials); void login(const chat::Credentials &credentials); void logout(); void sendFile(const QUrl &url); void sendFiles(const QList<QUrl> &urls); void sendMessage(const chat::ChatMessage &message);
ClientWorker
提供的槽有点像ChatEngine
公开的 API。ClientWorker
在专用线程中运行,在后台处理昂贵的操作,如传输或接收大文件。
m_clientWorker->moveToThread(&m_clientThread); m_clientThread.start(); connect(&m_clientThread, &QThread::finished, m_clientWorker, &QObject::deleteLater); connect(m_clientWorker, &ClientWorker::registerFinished, this, &ChatEngine::registerFinished); connect(m_clientWorker, &ClientWorker::chatError, this, &ChatEngine::chatError); ...
在ChatEngine
的构造函数中,我们将ClientWorker
分配给它的专用工作线程,并继续处理和转发它的信号,使它们在 QML 端可用。
void ChatEngine::registerUser(const chat::Credentials &credentials) { QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::registerUser, credentials); } ... void ClientWorker::registerUser(const chat::Credentials &credentials) { if (credentials.name().isEmpty() || credentials.password().isEmpty()) { emit chatError(tr("Invalid credentials for registration")); return; } if ((!m_client || m_hostUriDirty) && !initializeClient()) { emit chatError(tr("Failed registration: unabled to initialize client")); return; } auto reply = m_client->Register(credentials, QGrpcCallOptions{}.setDeadlineTimeout(5s)); const auto *replyPtr = reply.get(); connect( replyPtr, &QGrpcCallReply::finished, this, [this, reply = std::move(reply)](const QGrpcStatus &status) { emit registerFinished(status); }, Qt::SingleShotConnection); }
这演示了ChatEngine
如何与ClientWorker
交互注册用户。由于ClientWorker
在自己的线程中运行,因此必须使用invokeMethod 来安全地调用其成员函数。
在ClientWorker
中,我们会检查客户端是否未初始化或主机 URI 是否已更改。如果满足其中任一条件,我们就会调用initializeClient
,创建一个新的QGrpcHttp2Channel 。由于这种操作代价高昂,因此我们尽量减少它的出现次数。
为了处理Register
RPC,我们使用setDeadlineTimeout 选项来防止服务器不活动。一般建议为单向 RPC 设置截止时间。
void ClientWorker::login(const chat::Credentials &credentials) { if (credentials.name().isEmpty() || credentials.password().isEmpty()) { emit chatError(tr("Invalid credentials for login")); return; } ... QGrpcCallOptions opts; opts.setMetadata({ { "user-name", credentials.name().toUtf8() }, { "user-password", credentials.password().toUtf8() }, }); connectStream(opts); }
登录ChatRoom
时,我们使用setMetadata 选项提供服务器所需的用户凭证,以进行身份验证。实际调用和连接设置由connectStream
方法处理。
void ClientWorker::connectStream(const QGrpcCallOptions &opts) { ... m_chatStream = m_client->ChatRoom(*initialMessage, opts); ... connect(m_chatStream.get(), &QGrpcBidiStream::finished, this, [this, opts](const QGrpcStatus &status) { if (m_chatState == ChatState::Connected) { // If we're connected retry again in 250 ms, no matter the error. QTimer::singleShot(250, [this, opts]() { connectStream(opts); }); } else { setState(ChatState::Disconnected); m_chatResponse = {}; m_userCredentials = {}; m_chatStream.reset(); emit chatStreamFinished(status); } }); ...
我们实现了基本的重新连接逻辑,以防流在我们仍在连接时突然结束。只需使用初始调用的QGrpcCallOptions
再次调用connectStream
即可。这将确保所有必要的连接也得到更新。
注意: Android 的 "打盹/应用程序待机"模式可以通过使用 FileDialog 或切换到其他应用程序等方式触发。该模式会关闭网络访问,关闭所有活动的QTcpSocket 连接,并导致数据流finished 。我们通过重新连接逻辑来解决这一问题。
connect(m_chatStream.get(), &QGrpcBidiStream::messageReceived, this, [this]{ ...switch(m_chatResponse.contentField()) {casechat::ChatMessage::ContentFields::UninitializedField: qDebug("Received uninitialized message"); return;casechat::ChatMessage::ContentFields::Text:if(m_chatResponse.text().content().isEmpty()return;break;casechat::ChatMessage::ContentFields::File:// Download any file messages and store the downloaded URL in the // content, allowing the model to reference it from there. m_chatResponse.file() .setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());break; ...emitchatStreamMessageReceived(m_chatResponse); }); setState(Backend::ChatState::Connecting); }
收到消息时,ClientWorker
会执行一些预处理,例如保存FileMessage
内容,这样ChatEngine
只需关注模型。我们使用ContentFields
枚举来安全地检查 ChatMessage 和类型的oneof content
字段。
void ChatEngine::sendText(const QString &message) { if (message.trimmed().isEmpty()) return; if (auto request = m_clientWorker->createMessage()) { chat::TextMessage tmsg; tmsg.setContent(message.toUtf8()); request->setText(std::move(tmsg)); QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::sendMessage, *request); m_chatMessageModel->appendMessage(*request); } } ... void ClientWorker::sendMessage(const chat::ChatMessage &message) { if (!m_chatStream || m_chatState != ChatState::Connected) { emit chatError(tr("Unable to send message")); return; } m_chatStream->writeMessage(message); }
发送消息时,ChatEngine
会创建格式正确的请求。例如,sendText
方法接受QString
,并使用createMessage
函数生成一条有效的消息,并设置username
和timestamp
字段。然后调用客户端发送消息,并将副本挂起到我们自己的ChatMessageModel
中。
QML 前端
import QtGrpc import QtGrpcChat import QtGrpcChat.Proto
QML 代码中使用了以下导入:
QtGrpc
:提供QtGrpc QML 功能,如StatusCode 。QtGrpcChat
:我们的应用模块,包括ChatEngine
singleton 等组件。QtGrpcChat.Proto
:提供对我们生成的protobuf 类型的 QML 访问。
Connections { target: ChatEngine function onChatStreamFinished(status) { root.handleStatus(status) loginView.clear() } function onChatStateChanged() { if (ChatEngine.chatState === Backend.ChatState.Connected && mainView.depth === 1) mainView.push("ChatView.qml") else if (ChatEngine.chatState === Backend.ChatState.Disconnected && mainView.depth > 1) mainView.pop() } function onRegisterFinished(status) { root.handleStatus(status) } function onChatError(message) { statusDisplay.text = message statusDisplay.color = "yellow" statusDisplay.restart() } }
在Main.qml
中,我们处理由ChatEngine
发出的核心信号。这些信号大多是全局处理的,在应用程序的任何状态下都可视化。
Rectangle { id: root property credentials creds ... ColumnLayout { id: credentialsItem ... RowLayout { id: buttonLayout ... Button { id: loginButton ... enabled: nameField.text && passwordField.text text: qsTr("Login") onPressed: { root.creds.name = nameField.text root.creds.password = passwordField.text ChatEngine.login(root.creds) } }
从 protobuf 模式生成的消息类型可在 QML 中访问,因为它们是QML_VALUE_TYPEs(消息定义的camelCase版本)。LoginView.qml
使用credentials
值类型属性启动ChatEngine
上的login
。
ListView { id: chatMessageView ... component DelegateBase: Item { id: base required property chatMessage display default property alias data: chatLayout.data ... } ... // We use the DelegateChooser and the 'whatThis' role to determine // the correct delegate for any ChatMessage delegate: DelegateChooser { role: "whatsThis" ... DelegateChoice { roleValue: "text" delegate: DelegateBase { id: dbt TextDelegate { Layout.fillWidth: true Layout.maximumWidth: root.maxMessageBoxWidth Layout.preferredHeight: implicitHeight Layout.bottomMargin: root.margin Layout.leftMargin: root.margin Layout.rightMargin: root.margin message: dbt.display.text selectionColor: dbt.lightColor selectedTextColor: dbt.darkColor } } }
在ChatView.qml
中,ListView 在ChatRoom
中显示消息。这稍微复杂一些,因为我们需要有条件地处理ChatMessage
sum 类型。
为了处理这个问题,我们使用了 DelegateChooser,它允许我们根据消息类型选择适当的委托。我们使用模型中默认的whatThis
角色,它为每个ChatMessage
实例提供了消息类型。然后,DelegateBase
组件访问模型的display
角色,使 chatMessage 数据可供呈现。
TextEdit { id: root required property textMessage message text: message.content color: "#f3f3f3" font.pointSize: 14 wrapMode: TextEdit.Wrap readOnly: true selectByMouse: true }
下面是可视化TextMessage
类型的组件之一。它使用来自 protobuf 模块的textMessage
值类型来可视化文本。
TextArea.flickable: TextArea { id: inputField function sendTextMessage() : void { if (text === "") return ChatEngine.sendText(text) text = "" } ... Keys.onPressed: (event) => { if (event.key === Qt.Key_Return && event.modifiers & Qt.ControlModifier) { sendTextMessage() event.accepted = true } else if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { if (ChatEngine.sendFilesFromClipboard()) event.accepted = true } }
聊天客户端为发送信息提供了各种接入点,例如
- 接受投递到应用程序上的文件。
- <Ctrl + V> 发送存储在 QClipboard 中的任何内容。
- <Ctrl + Enter> 从 QClipboard 发送信息。
inputField
- 点击发送按钮
inputField
- 通过文件对话框选择文件
SSL
为确保服务器和客户端之间的通信安全,使用了 SSL/TLS 加密技术。这至少需要以下条件:
- 私钥:包含服务器的私钥,用于建立安全连接。它必须保密,绝不能共享。
- 证书:包含服务器的公共证书,与客户端共享以验证服务器的身份。它通常由证书颁发机构(CA)签署,也可出于测试目的自行签署。
- 可选的根 CA 证书:如果使用自定义证书颁发机构(CA)签署服务器证书,则客户端需要根 CA 证书来验证服务器的证书链。这将确保客户端可以信任服务器证书,因为自定义 CA 的根证书不像公共 CA 的根证书那样预先安装在客户端的信任存储中。
我们使用OpenSSL创建这些文件,并将gRPC 通信设置为使用 SSL/TLS。
grpc::SslServerCredentialsOptions sslOpts; sslOpts.pem_key_cert_pairs.emplace_back(grpc::SslServerCredentialsOptions::PemKeyCertPair{ LocalhostKey, LocalhostCert, }); builder.AddListeningPort(QtGrpcChatService::httpsAddress(), grpc::SslServerCredentials(sslOpts)); builder.AddListeningPort(QtGrpcChatService::httpAddress(), grpc::InsecureServerCredentials());
我们向gRPC 服务器提供私钥和证书。这样,我们就可以构建SslServerCredentials
,在服务器端启用 TLS。除了安全通信外,我们还允许未加密访问。
服务器通过以下地址监听:
- HTTPS :
0.0.0.0:65002
- HTTP :
0.0.0.0:65003
服务器绑定0.0.0.0
,监听所有网络接口,允许同一网络上的任何设备访问。
if(m_hostUri.scheme()== "https") {if(!QSslSocket::supportsSsl()) {emitchatError(tr("The device doesn't support SSL. Please use the 'http' scheme."));return false; } QFilecrtFile(":/res/root.crt");if(!crtFile.open(QFile::ReadOnly)) { qFatal("Unable to load root certificate"); return false; } QSslConfigurationsslConfig.addCaCertificate(crt) QSslCertificatecrt(crtFile.readAll()); sslConfig.addCaCertificate(crt); sslConfig.setProtocol(QSsl::TlsV1_2OrLater); sslConfig.setAllowedNextProtocols({"h2"});// Allow HTTP/2 // Disable hostname verification to allow connections from any local IP. // Acceptable for development but avoid in production for security.QSslSocket::VerifyNone); opts.setSslConfiguration(sslConfig); }
客户端加载根 CA 证书,因为我们自签了 CA。该证书用于创建QSslCertificate 。由于我们使用的是 HTTP/2,因此必须在setAllowedNextProtocols 中提供"h2"
协议。
运行示例
- 确保
qtgrpc_chat_server
正在运行并成功监听。 - 如果与服务器在同一台机器上,运行
qtgrpc_chat_client
时使用默认的localhost
地址即可。如果使用的不是服务器主机,请在 "设置 "对话框中指定运行服务器的主机的正确 IP 地址。 - 确保在客户端启用
GRPC_CHAT_USE_EMOJI_FONT
CMake 选项,以获得流畅的表情符号体验🚀。
要从 Qt Creator,打开Welcome 模式并从Examples 中选择示例。更多信息,请参阅Qt Creator: 教程:构建并运行。
© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.