gRPCを使用して.Net Coreでシンプルなチャットサーバーを構築する

この記事では、シンプルなコンカレント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‘という名前で作成されます。
PowerShell

 

dotnet new grpc -n ChatServerApp

  • 選択したエディタでプロジェクトを開きます。MacではVisual Studioを使用しています。

Step 2: ProtoファイルでProtobufメッセージを定義する

Protobuf契約:

  1. protosフォルダ内にserver.protoという名前の.protoファイルを作成します。Protoファイルは、サービスの構造、メッセージタイプ、およびサービスがサポートするメソッドを定義するために使用されます。
ProtoBuf

 

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のストリームを出力として返します。
ProtoBuf

 

service ChatServer {
  // クライアントとサーバーの間の双方向通信ストリーム
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin,クライアントから送信されるメッセージで、chat_room_idとuser_nameという2つのフィールドを持ちます。このメッセージタイプは、クライアントからサーバーへのログイン情報を送信するために使用されます。chat_room_idフィールドは、クライアントが参加したいチャットルームを指定し、user_nameフィールドは、チャットルームでクライアントが使用したいユーザー名を指定します
ProtoBuf

 

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


  • ClientMessageChatは、クライアントからサーバーへのチャットメッセージを送信するために使用されます。これは、textという単一のフィールドを含んでいます.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessageは、クライアントがサーバーに送信できる異なるタイプのメッセージを定義します。oneofフィールドを含んでおり、一度に1つのフィールドのみ設定できることを意味します。oneofを使用すると、生成されるC#コードに、設定されたフィールドを示す列挙型が含まれます。フィールド名は”login“と”chat“で、それぞれClientMessageLoginClientMessageChatメッセージに対応しています。
ProtoBuf

 

message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

  • ServerMessageLoginFailure は、クライアントがチャットルームにログインできなかったことを示すためにサーバーが送信するメッセージを定義します。失敗の理由は、reasonフィールドで指定されます。
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess は、クライアントがチャットルームに正常にログインしたことを示すためにサーバーが送信するメッセージを定義します。このメッセージにはフィールドは含まれず、ログインが成功したことを単に示します。クライアントがClientMessageLoginメッセージを送信すると、サーバーはログインが成功したかどうかに応じてServerMessageLoginSuccessメッセージまたはServerMessageLoginFailureメッセージのいずれかを返します。ログインが成功した場合、クライアントはClientMessageChatメッセージを送信してチャットを開始できます。
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • メッセージServerMessageUserJoined は、新しいユーザーがチャットルームに参加した際にサーバーがクライアントに送信するメッセージを定義します。
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • メッセージServerMessageChatは、新しいチャットメッセージが受信されたことを示すためにサーバーが送信するメッセージを定義します。チャットメッセージの内容はtextフィールドで、メッセージを送信したユーザーの名前はuser_nameフィールドで指定されます。
ProtoBuf

 

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • メッセージServerMessageは、サーバからクライアントへ送信可能なさまざまなタイプのメッセージを定義します。これには、複数のオプションを持つoneof フィールドであるcontentという名前のフィールドが含まれています。フィールド名は”login_success“、”login_failure“、”user_joined“、および”chat“であり、それぞれServerMessageLoginSuccessServerMessageLoginFailureServerMessageUserJoined、およびServerMessageChatメッセージに対応しています。
ProtoBuf

 

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メソッドの実装は、クライアントとサーバの間の通信を処理する責任があります。

C#

 

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ファイルで:

C#

 

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/2TLSで使用するように設定します。

ステップ5: ChatRoomServiceを作成し、HandleCommunicationで必要とされるさまざまなメソッドを実装する

ChatRoomServiceクラスは、チャットルームとクライアントを管理し、クライアント間で送信されるメッセージを処理する責任があります。チャットルームを格納するためにConcurrentDictionaryを使用し、各ルームのChatClientオブジェクトのリストを保持します。AddClientToChatRoomメソッドは新しいクライアントをチャットルームに追加し、BroadcastClientJoinedRoomMessageメソッドは新しいクライアントがルームに参加したときにルーム内のすべてのクライアントにメッセージを送信します。BroadcastMessageToChatRoomメソッドは、メッセージの送信者を除くルーム内のすべてのクライアントにメッセージを送信します。

ChatClientクラスは、クライアントにメッセージを書き込むためのStreamWriterオブジェクトと、クライアントを識別するためのUserNameプロパティを含んでいます。

C#

 

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が有効かどうかをチェックし、それに応じてクライアントに応答メッセージを送信します。ログインが成功すると、クライアントがチャットルームに追加され、チャットルーム内のすべてのクライアントにブロードキャストメッセージが送信されます。
  • チャットケースでは、メソッドがチャットルーム内のすべてのクライアントにメッセージをブロードキャストします。
C#

 

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