QtGrpc チャット
チャットルームであらゆる種類のメッセージを共有するためのチャットアプリケーションです。
Chat の例では、QtGrpc クライアント API の高度な使い方を示します。サーバはユーザが登録と認証を行い、ChatRoomに参加できるようにします。一度参加すると、ユーザーはChatRoomでテキストメッセージ、画像、ユーザーのアクティビティ、または他の参加者全員とディスクから他のファイルなど、様々なメッセージタイプを共有することができます。
この例で説明する主なトピックは次のとおりです:
- 長期間のQGrpcBidiStreamを介したコミュニケーション。
- ワーカーthread からのQtGrpc クライアントの使用。
- protobuf スキーマでのQtProtobufQtCoreTypes モジュールの使用。
- SSL による安全な通信。
- QML でQtProtobuf メッセージを視覚化するListView.
注意: 「例の実行」の前提条件を必ず読んでください。
Protobufスキーマ
Protobufスキーマは、チャットアプリケーションで使用されるメッセージとサービスの構造を定義します。スキーマは2つのファイルに分かれています:
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
ファイルは、2 つの RPC メソッドを提供する QtGrpcChat サービスを指定します:
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
を含める必要があります。
QtProtobufQtCoreTypes モジュールの型を有効にするために、QtCore/QtCore.proto
インポートを含め、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
は、 sum型でサポートされているメッセージ型の1つです。これは、任意のローカルファイルをメッセージにラップすることができます。オプションの フィールドは、大きなファイル転送をチャンクで処理することで、信頼性の高い配信を保証します。ChatMessage
Continuation
注: ProtobufスキーマとアプリケーションコードでProtobufQtCoreTypes
モジュールを使用する詳細については、以下を参照してください。 Qt Core usage.
サーバー
注: ここで説明するサーバーアプリケーションは gRPC™ライブラリを使用します。
サーバー・アプリケーションでは、非同期のgRPC コールバック API を使用しています。これにより、手動で完了キューを管理する複雑さを伴うことなく、非同期APIのパフォーマンス上の利点を享受することができる。
class QtGrpcChatService final : public chat::QtGrpcChat::CallbackService
生成されたQtGrpcChat
サービスのCallbackService
をサブクラス化したQtGrpcChatService
クラスを宣言します。
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
サービスによって提供される2つのメソッド(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
の可視化と通信が可能になります。
セットアップ
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 の生成のみが必要です。また、"QtCore/QtCore.proto"
のインポートが有効であることを保証するために、ProtobufQtCoreTypes
モジュールのPROTO_INCLUDES
も提供します。
target_link_libraries(qtgrpc_chat_client_proto PUBLIC Qt6::Protobuf Qt6::ProtobufQtCoreTypes Qt6::Grpc )
独立したqtgrpc_chat_client_proto
ターゲットが、ProtobufQtCoreTypes
モジュールを含む依存関係に対して公にリンクされていることを確認します。アプリケーション・ターゲットは、このライブラリに対してリンクされる。
バックエンドロジック
アプリケーションのバックエンドは、4つの重要な要素を中心に構築されている:
ChatEngine
:アプリケーションのロジックを管理するQML向けのシングルトン。ClientWorker
:gRPC クライアント機能を非同期に提供するワーカーオブジェクト。ChatMessageModel
:ChatMessage
の処理と保存のためのカスタム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のDoze/App-Standbyモードは、FileDialogを使用したり、別のアプリに切り替えたりすることなどでトリガーできます。このモードではネットワークアクセスがシャットダウンされ、アクティブなQTcpSocket 接続がすべて閉じられ、ストリームがfinished になります。この問題は、再接続ロジックで対処します。
コネクト(m_chatStream.get(), &::messageReceived, this)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:// 任意のファイルメッセージをダウンロードし、ダウンロードしたURLを // コンテンツに 保存 し、モデルがそこから参照できるようにする。 m_chatResponse.file() .setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());break; ...chatStreamMessageReceived(m_chatResponse); }); setState(Backend::ChatState::Connecting); }
メッセージが受信されると、ClientWorker
はFileMessage
の内容を保存するなどの前処理を行い、ChatEngine
はモデルだけに集中すればよいようにします。ContentFields
enumを使用して、ChatMessage sum型の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
:StatusCode のようなQtGrpc QML の機能を提供します。QtGrpcChat
:ChatEngine
シングルトンのようなコンポーネントを含むアプリケーションモジュール。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_VALUE_TYPE(メッセージ定義のキャメルケースバージョン)であるため、QMLでアクセス可能です。LoginView.qml
はcredentials
の値型プロパティを使ってlogin
をChatEngine
で開始します。
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
の和型を条件付きで扱う必要があるため、少し複雑になります。
これを処理するために、DelegateChooserを使用します。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
型を視覚化するコンポーネントの1つです。これは、テキストを視覚化するためにprotobufモジュールのtextMessage
value型を使用しています。
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
- FileDialogでファイルを選択する
SSL
サーバーとクライアント間の通信を保護するために、SSL/TLS暗号化が使用されます。これには最低でも以下のものが必要です:
- 秘密鍵:サーバーの秘密鍵が含まれ、安全な接続を確立するために使用される。秘密鍵は秘密にしておかなければならず、決して共有してはならない。
- 証明書:サーバーの公開証明書が含まれ、サーバーの身元を確認するためにクライアントと共有される。通常、認証局(CA)によって署名されるが、テスト目的で自己署名することもできる。
- オプシ ョ ンのルー ト CA 証明書:カスタム認証局(CA)を使用してサーバー証明書に署名する場合、ルートCA証明書は、サーバーの証明書チェーンを検証するためにクライアント側で必要となります。カスタムCAのルート証明書は、パブリックCAのようにクライアントのトラストストアにプリインストールされていないため、クライアントがサーバー証明書を信頼できることを保証します。
OpenSSLを使用してこれらのファイルを作成し、SSL/TLSを使用するようにgRPC 。
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()) { chatError(tr("デバイスはSSLをサポートしていません。 'http'スキームを使用してください。"));return false; } もし(! QFilecrtFile(":/res/root.crt");if(!crtFile.open( ::ReadOnly)) { emit chatError(tr("デバイスはSSLをサポートしていません。QFile::ReadOnly)){の場合 qFatal("Unable to load root certificate"); 戻り値 false; }. QSslConfigurationsslConfig; QSslCertificatecrt(crtFile.readAll()); sslConfig.addCaCertificate(crt); sslConfig.setProtocol(QSslsslConfig.setProtocols(::TlsV1_2OrLater); sslConfig.setAllowedNextProtocols({"h2"});// HTTP/2を許可する // 任意のローカルIPからの接続を許可するため、ホスト名検証を無効にする 。QSslSocket::VerifyNone); opts.setSslConfiguration(sslConfig); }
CA を自己署名したので、クライアントはルート CA 証明書をロードします。この証明書は、QSslCertificate を作成するために使用される。HTTP/2を使用しているため、setAllowedNextProtocols で"h2"
プロトコルを提供することが重要である。
例の実行
qtgrpc_chat_server
が実行され、正常にリスンしていることを確認する。- サーバーと同じマシン上にいる場合は、デフォルトの
localhost
アドレスで十分です。qtgrpc_chat_client
を実行する場合、サーバーをホストしているデバイス以外のデバイスを使用している場合は、設定ダイアログでサーバーを実行しているホストの正しい 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.