QtGrpc チャット
チャットルームであらゆる種類のメッセージを共有するためのチャットアプリケーションです。
Chat の例では、QtGrpc クライアント API の高度な使い方を示します。サーバはユーザが登録と認証を行い、ChatRoomに参加できるようにします。一度参加すると、ユーザーはChatRoomでテキストメッセージ、画像、ユーザーのアクティビティ、または他の参加者全員とディスクから他のファイルなど、様々なメッセージタイプを共有することができます。
例の実行
qtgrpc_chat_server
が起動しており、正常にリスニングできていることを確認してください。qtgrpc_chat_client
サーバーと同じマシンにいる場合は、デフォルトのlocalhost
アドレスで十分です。サーバーをホストしているデバイス以外のデバイスを使用している場合は、設定ダイアログでサーバーを実行しているホストの正しい IP アドレスを指定してください。- クライアントで
GRPC_CHAT_USE_EMOJI_FONT
CMakeオプションが有効になっていることを確認し、スムーズな絵文字エクスペリエンス🚀でビルドしてください。
からサンプルを実行するには Qt Creatorからサンプルを実行するには、Welcome モードを開き、Examples からサンプルを選択します。詳細については、Qt Creator:チュートリアルを参照してください:ビルドと実行。
関連モジュールとクラス。
この例では、以下の Qt モジュールとクラスを紹介します。
- 長寿命のQGrpcBidiStream を介した通信。
- ワーカーからのQtGrpc クライアントの使用thread.
- 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
を含める必要があります。
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
sum型でサポートされているメッセージ型の1つです。これは、任意のローカルファイルをメッセージにラップすることができます。オプションの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を使用すると、メッセージのタイプに基づいて適切なデリゲートを選択できます。モデルのデフォルトの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 証明書をロードします。この証明書は、QSslCertificate 。HTTP/2を使用しているため、setAllowedNextProtocols で"h2"
プロトコルを提供することが重要です。
ソース・ファイル
すべての Qt サンプルも参照してください 。
© 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.