QtGrpc 聊天

一款聊天应用程序,用于在聊天室中共享任何类型的信息。

聊天示例演示了QtGrpc 客户端 API 的高级用法。服务器可让用户注册并通过身份验证,从而加入聊天室。一旦加入,用户就可以在聊天室中与所有其他参与者共享各种类型的信息,如文本信息、图片、用户活动或磁盘中的任何其他文件。

本例中涉及的一些关键主题包括

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 都必须包含usernametimestamp ,以标识发送方。

我们使用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 方法的长期双向流。这使得ChatMessages 的可视化和通信成为可能。

设置
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:用于处理和存储ChatMessages 的自定义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 函数生成一条有效的消息,并设置usernametimestamp 字段。然后调用客户端发送消息,并将副本挂起到我们自己的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 中,ListViewChatRoom 中显示消息。这稍微复杂一些,因为我们需要有条件地处理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: 教程:构建并运行

示例项目 @ code.qt.io

© 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.