この記事では、シンプルなコンカレントgRPCチャットサーバーアプリケーションを作成します。クロスプラットフォーム、オープンソース、モジュール型のフレームワークである.NET Coreを使用して、チャットサーバーアプリケーションを構築します。以下のトピックについて説明します。
- A brief introduction to gRPC
- gRPC環境のセットアップとサービスコントラクトの定義
- チャットサービスの実装とクライアントリクエストの処理
- 非同期プログラミングを使用した複数クライアントの同時処理
- 同じルームに接続されているすべてのクライアントにチャットメッセージをブロードキャスト
このチュートリアルを終えることで、gRPCを使ってチャットサーバーを構築する方法を理解できるでしょう。
gRPCとは何か?
gRPCは、Google Remote Procedure Callsの略です。Googleによって最初に開発され、現在はCloud Native Computing Foundation(CNCF)によって管理されています。gRPCを使用すると、ローカル関数呼び出しと同様に簡単に、分散型の異種アプリケーションを接続、呼び出し、操作、およびデバッグできます。
gRPCはHTTP/2をトランスポートとして使用し、API開発のためのコントラクトファーストのアプローチ、およびインターフェース定義言語としてのProtocol Buffers(Protobuf)とその下のメッセージ交換フォーマットを使用します。4種類のAPI(Unary RPC、Server streaming RPC、Client streaming RPC、およびBidirectional streaming RPC)をサポートしています。gRPCについてさらに詳しく知りたい方はこちらをご覧ください。
はじめに
コードを書き始める前に、.NET Coreのインストールが必要です。以下の前提条件が用意されていることを確認してください:
- Visual Studio Code、Visual Studio、またはJetBrains Rider IDE
- .NET Core
- gRPC .NET
- Protobuf
Step 1: Visual StudioまたはコマンドラインからgRPCプロジェクトを作成する
- 次のコマンドを使用して新しいプロジェクトを作成できます。成功した場合、指定したディレクトリに’ChatServer‘という名前で作成されます。
dotnet new grpc -n ChatServerApp
- 選択したエディタでプロジェクトを開きます。MacではVisual Studioを使用しています。
Step 2: ProtoファイルでProtobufメッセージを定義する
Protobuf契約:
- protosフォルダ内にserver.protoという名前の.protoファイルを作成します。Protoファイルは、サービスの構造、メッセージタイプ、およびサービスがサポートするメソッドを定義するために使用されます。
syntax = "proto3";
option csharp_namespace = "ChatServerApp.Protos";
package chat;
service ChatServer {
// クライアントとサーバー間の双方向通信ストリーム
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
//クライアントメッセージ:
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
message ClientMessageChat {
string text = 1;
}
//サーバーメッセージ
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
message ServerMessageLoginFailure {
string reason = 1;
}
message ServerMessageLoginSuccess {
}
message ServerMessageUserJoined {
string user_name = 1;
}
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
ChatServer
は、私たちのチャットアプリケーションの主要サービスを定義し、HandleCommunication
と呼ばれる単一のRPCメソッドを含んでいます。このメソッドは、クライアントとサーバー間の双方向ストリーミングに使用されます。ClientMessage
のストリームを入力として受け取り、ServerMessage
のストリームを出力として返します。
service ChatServer {
// クライアントとサーバーの間の双方向通信ストリーム
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
ClientMessageLogin
,クライアントから送信されるメッセージで、chat_room_idとuser_nameという2つのフィールドを持ちます。このメッセージタイプは、クライアントからサーバーへのログイン情報を送信するために使用されます。chat_room_id
フィールドは、クライアントが参加したいチャットルームを指定し、user_name
フィールドは、チャットルームでクライアントが使用したいユーザー名を指定します
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
ClientMessageChat
は、クライアントからサーバーへのチャットメッセージを送信するために使用されます。これは、text
という単一のフィールドを含んでいます.
message ClientMessageChat {
string text = 1;
}
ClientMessage
は、クライアントがサーバーに送信できる異なるタイプのメッセージを定義します。oneofフィールドを含んでおり、一度に1つのフィールドのみ設定できることを意味します。oneof
を使用すると、生成されるC#コードに、設定されたフィールドを示す列挙型が含まれます。フィールド名は”login
“と”chat
“で、それぞれClientMessageLogin
とClientMessageChat
メッセージに対応しています。
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
ServerMessageLoginFailure
は、クライアントがチャットルームにログインできなかったことを示すためにサーバーが送信するメッセージを定義します。失敗の理由は、reasonフィールドで指定されます。
message ServerMessageLoginFailure {
string reason = 1;
}
-
ServerMessageLoginSuccess
は、クライアントがチャットルームに正常にログインしたことを示すためにサーバーが送信するメッセージを定義します。このメッセージにはフィールドは含まれず、ログインが成功したことを単に示します。クライアントがClientMessageLogin
メッセージを送信すると、サーバーはログインが成功したかどうかに応じてServerMessageLoginSuccess
メッセージまたはServerMessageLoginFailure
メッセージのいずれかを返します。ログインが成功した場合、クライアントはClientMessageChat
メッセージを送信してチャットを開始できます。
message ServerMessageLoginSuccess {
}
- メッセージ
ServerMessageUserJoined
は、新しいユーザーがチャットルームに参加した際にサーバーがクライアントに送信するメッセージを定義します。
message ServerMessageUserJoined {
string user_name = 1;
}
- メッセージ
ServerMessageChat
は、新しいチャットメッセージが受信されたことを示すためにサーバーが送信するメッセージを定義します。チャットメッセージの内容はtext
フィールドで、メッセージを送信したユーザーの名前はuser_name
フィールドで指定されます。
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
- メッセージ
ServerMessage
は、サーバからクライアントへ送信可能なさまざまなタイプのメッセージを定義します。これには、複数のオプションを持つoneof
フィールドであるcontentという名前のフィールドが含まれています。フィールド名は”login_success
“、”login_failure
“、”user_joined
“、および”chat
“であり、それぞれServerMessageLoginSuccess
、ServerMessageLoginFailure
、ServerMessageUserJoined
、およびServerMessageChat
メッセージに対応しています。
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
ステップ3: ChatService
クラスを追加
ChatService
クラスをChatServerBase
(server.protoファイルからgRPCコードジェネレータprotocによって生成されたもの)から派生させ、HandleCommunication
メソッドをオーバーライドします。HandleCommunication
メソッドの実装は、クライアントとサーバの間の通信を処理する責任があります。
public class ChatService : ChatServerBase
{
private readonly ILogger<ChatService> _logger;
public ChatService(ILogger<ChatService> logger)
{
_logger = logger;
}
public override Task HandleCommunication(IAsyncStreamReader<ClientMessage> requestStream, IServerStreamWriter<ServerMessage> responseStream, ServerCallContext context)
{
return base.HandleCommunication(requestStream, responseStream, context);
}
}
ステップ4: gRPCの設定
program.csファイルで:
using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
/*
// macOSでgRPCを正常に実行するためには、追加の設定が必要です。
// macOSでKestrelとgRPCクライアントを設定する手順については、
// https://go.microsoft.com/fwlink/?linkid=2099682を参照してください
To avoid missing ALPN support issue on Mac. To work around this issue, configure Kestrel and the gRPC client to use HTTP/2 without TLS.
You should only do this during development. Not using TLS will result in gRPC messages being sent without encryption.
https://learn.microsoft.com/en-us/aspnet/core/grpc/troubleshoot?view=aspnetcore-7.0
*/
builder.WebHost.ConfigureKestrel(options =>
{
// TLSなしでHTTP/2エンドポイントをセットアップします。
options.ListenLocalhost(50051, o => o.Protocols =
HttpProtocols.Http2);
});
// コンテナーにサービスを追加します。
builder.Services.AddGrpc();
builder.Services.AddSingleton();
var app = builder.Build();
// HTTPリクエストパイプラインを設定します。
app.MapGrpcService();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
Console.WriteLine($"gRPC server about to listening on port:50051");
app.Run();
注意: ASP.NET Core gRPC テンプレートおよびサンプルは、デフォルトでTLSを使用します。しかし、開発目的では、KestrelとgRPCクライアントをHTTP/2非TLSで使用するように設定します。
ステップ5: ChatRoomService
を作成し、HandleCommunication
で必要とされるさまざまなメソッドを実装する
ChatRoomService
クラスは、チャットルームとクライアントを管理し、クライアント間で送信されるメッセージを処理する責任があります。チャットルームを格納するためにConcurrentDictionary
を使用し、各ルームのChatClient
オブジェクトのリストを保持します。AddClientToChatRoom
メソッドは新しいクライアントをチャットルームに追加し、BroadcastClientJoinedRoomMessage
メソッドは新しいクライアントがルームに参加したときにルーム内のすべてのクライアントにメッセージを送信します。BroadcastMessageToChatRoom
メソッドは、メッセージの送信者を除くルーム内のすべてのクライアントにメッセージを送信します。
ChatClient
クラスは、クライアントにメッセージを書き込むためのStreamWriter
オブジェクトと、クライアントを識別するためのUserNameプロパティを含んでいます。
using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;
namespace ChatServer.Services
{
public class ChatRoomService
{
private static readonly ConcurrentDictionary> _chatRooms = new ConcurrentDictionary>();
クライアントからの単一メッセージを読み取る。
public async Task ReadMessageWithTimeoutAsync(IAsyncStreamReader requestStream, TimeSpan timeout)
{
CancellationTokenSource cancellationTokenSource = new();
cancellationTokenSource.CancelAfter(timeout);
try
{
bool moveNext = await requestStream.MoveNext(cancellationTokenSource.Token);
if (moveNext == false)
{
throw new Exception("connection dropped exception");
}
return requestStream.Current;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
throw new TimeoutException();
}
}
public async Task AddClientToChatRoom(string chatRoomId, ChatClient chatClient)
{
if (!_chatRooms.ContainsKey(chatRoomId))
{
_chatRooms[chatRoomId] = new List { chatClient };
}
else
{
var existingUser = _chatRooms[chatRoomId].FirstOrDefault(c => c.UserName == chatClient.UserName);
if (existingUser != null)
{
同じユーザー名のユーザーがチャットルームに既に存在します
throw new InvalidOperationException("User with the same name already exists in the chat room");
}
_chatRooms[chatRoomId].Add(chatClient);
}
await Task.CompletedTask;
}
クライアントが部屋に参加したメッセージを広くする。
public async Task BroadcastClientJoinedRoomMessage(string userName, string chatRoomId)
{
if (_chatRooms.ContainsKey(chatRoomId))
{
var message = new ServerMessage { UserJoined = new ServerMessageUserJoined { UserName = userName } };
var tasks = new List();
foreach (var stream in _chatRooms[chatRoomId])
{
if (stream != null && stream != default)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
public async Task BroadcastMessageToChatRoom(string chatRoomId, string senderName, string text)
{
if (_chatRooms.ContainsKey(chatRoomId))
{
var message = new ServerMessage { Chat = new ServerMessageChat { UserName = senderName, Text = text } };
var tasks = new List();
var streamList = _chatRooms[chatRoomId];
foreach (var stream in _chatRooms[chatRoomId])
{
このsenderNameは、各ユーザー向けのユニークIDとして何かのものであることがあります。
if (stream != null && stream != default && stream.UserName != senderName)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
}
public class ChatClient
{
public IServerStreamWriter StreamWriter { get; set; }
public string UserName { get; set; }
}
}
ステップ6: 最後に、ステップ3のgRPCのHandleCommunication
メソッドを実装します。
HandleCommunication
はクライアントからrequestStream
を受け取り、クライアントにresponseStream
を送り返します。このメソッドはクライアントからメッセージを読み取り、ユーザー名とchatRoomId
を抽出し、ログインケースとチャットケースの2つのケースを処理します。
- ログインケースでは、メソッドがユーザー名と
chatRoomId
が有効かどうかをチェックし、それに応じてクライアントに応答メッセージを送信します。ログインが成功すると、クライアントがチャットルームに追加され、チャットルーム内のすべてのクライアントにブロードキャストメッセージが送信されます。 - チャットケースでは、メソッドがチャットルーム内のすべてのクライアントにメッセージをブロードキャストします。
using System;
using ChatServer;
using Grpc.Core;
namespace ChatServer.Services
{
public class ChatService : ChatServer.ChatServerBase
{
private readonly ILogger _logger;
private readonly ChatRoomService _chatRoomService;
public ChatService(ChatRoomService chatRoomService, ILogger logger)
{
_chatRoomService = chatRoomService;
_logger = logger;
}
public override async Task HandleCommunication(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context)
{
var userName = string.Empty;
var chatRoomId = string.Empty;
while (true)
{
//クライアントからメッセージを読み取る。
var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);
switch (clientMessage.ContentCase)
{
case ClientMessage.ContentOneofCase.Login:
var loginMessage = clientMessage.Login;
//クライアントメッセージからユーザー名とchatRoomIdを取得する。
chatRoomId = loginMessage.ChatRoomId;
userName = loginMessage.UserName;
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
{
//ログイン失敗メッセージを送信する。
var failureMessage = new ServerMessage
{
LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
};
await responseStream.WriteAsync(failureMessage);
return;
}
//ログイン成功メッセージをクライアントに送信する。
var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
await responseStream.WriteAsync(successMessage);
//クライアントをチャットルームに追加する。
await _chatRoomService.AddClientToChatRoom(chatRoomId, new ChatClient
{
StreamWriter = responseStream,
UserName = userName
});
break;
case ClientMessage.ContentOneofCase.Chat:
var chatMessage = clientMessage.Chat;
if (userName is not null && chatRoomId is not null)
{
//ルームにメッセージをブロードキャストする。
await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
}
break;
}
}
}
}
}
プロジェクトディレクトリの完全なリスト:
それでパート1は以上です。次のパート2では、このチャットアプリケーションを完成させるためにクライアントプロジェクトを作成し、クライアント実装を行います。
Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7